Creating a bill of materials dialog in MFC

This section is provided as a practical example of creating a “bill of materials” dialog in MFC. However, the techniques used are applicable to any GUI system.

In the previous sections, we have seen how the properties associated with an IFC model can be used in tandem with the graphics data to enhance the UI experience. We looked at getting IFC properties, handling selection, generating cutplanes and using a subwindow to provide a plan view.

In this section, we’re going to use the functionality we created thus far, to create a Bill of Materials (BOM) dialog and then export the BOM to a PDF file.

This is the dialog we’re going to create:

../../_images/bom_dialog.png

This tutorial assumes that the reader has some familiarity with how to create a dialog in MFC. For more background on this topic, please visit this webpage: https://docs.microsoft.com/en-us/cpp/mfc/creating-and-displaying-dialog-boxes?view=vs-2019

Creating the dialog

For this example, we will create a modeless dialog. In your IDE, begin by going to the Resources tab and under the Dialog section click “Add Dialog”. This will create an empty dialog with a “Cancel” and “OK” button.

As you can see above, we don’t use the “Cancel” button, so you can either delete it or hide it. The main part of the dialog consists of a list control, using the “Report” style.

To insert a list control, go to the Toolbox, select “List Control” and insert one into the dialog. Once you have done this, select the new control and open up the Properties. Make sure you have selected the “Report” view. This will give us the grid-like style we need for this dialog.

Next, go back to the toolbox and add two new buttons, one to create a PDF report and the other to clear the contents of the table. Then, assign some meaningful resource IDs to the controls using the Properties window. For instance, in the case of the list control, the code samples use IDC_LIST_QUANTITY.

At this stage you will have a dialog but nothing will be hooked up to any functionality. The next step is to create a C++ class to represent the dialog and associated functionality.

Select the dialog and then on the context menu, select “Add Class”. The base class will be CDialogEx. Call the class CHPSComponentQuantitiesDialog.

Finally, create a control variable in the class which represents the list view. To create this select the list control in the dialog editor, then on the context menu, select “Add Variable”. Create a control variable of type CListCtrl. For the sample code, shown in this section, we named it mListQuantities.

Displaying the dialog

Now that we have a class which represents our dialog, we’re going to add some code to display it. In the same way we previously added a checkbox to control display of the properties pane, add a checkbox to display the quantities dialog.

First we need an object of the class CHPSComponentQuantitiesDialog. We’ll do that in CHPSFrame where all the other dialogs and panes are declared. Add a line like the following to the header file:

CHPSComponentQuantitiesDialog m_componentQuantitiesDlg;

We want a modeless dialog, therefore we call Create, not DoModal. In CHPSFrame::Create, add some code to create the dialog:

if (!m_componentQuantitiesDlg.Create(IDD_DLG_QUANTITY ))
{
        TRACE0("Failed to create Component Quantities dlg\n");
        return FALSE; // failed to create
}

Creating the table view

One time initialization of the controls takes place in OnInitDialog. It is in this function that we layout the table. As you can see, our report view contains 5 columns. These are set up using the following code:

LRESULT dwStyle;
dwStyle = mListQuantities.SendMessage(LVM_GETEXTENDEDLISTVIEWSTYLE, 0, 0);
dwStyle |= LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES;
mListQuantities.SendMessage(LVM_SETEXTENDEDLISTVIEWSTYLE, 0, dwStyle);

mListQuantities.InsertColumn(1, _T("Component Name          "));
mListQuantities.InsertColumn(2, _T("IFC Type                "));
mListQuantities.InsertColumn(3, _T("Quantity  "));
mListQuantities.InsertColumn(0, _T("Unit Cost "));
mListQuantities.InsertColumn(4, _T("Total Cost"));
mListQuantities.SetColumnWidth(0, LVSCW_AUTOSIZE_USEHEADER);
mListQuantities.SetColumnWidth(1, LVSCW_AUTOSIZE_USEHEADER);
mListQuantities.SetColumnWidth(2, LVSCW_AUTOSIZE_USEHEADER);
mListQuantities.SetColumnWidth(3, LVSCW_AUTOSIZE_USEHEADER);
mListQuantities.SetColumnWidth(4, LVSCW_AUTOSIZE_USEHEADER);
INT colOrder[] = { 1,2,3,0,4 };
mListQuantities.SetColumnOrderArray(5, colOrder);

You can see how we use the control variable to define the columns along with the column headers. The extended view style is used so that we can tell the control to display gridlines. You may also note that we modify the display order of the columns.

We do this way because we want to make the “Unit Cost” column editable, so that the user can input a value. It is possible with custom code to make all columns editable, but by default, this MFC class supports editing of the first column. If just one column needs to editable, then its much simpler to reorder the columns.

One more step, set the “Edit Label” property to true for the list control.

../../_images/idc_list_qty.png

Controlling visibility

We will control the visibility of the dialog programmatically. Add a function to the Frame to control the visibility:

void CHPSFrame::SetComponentQuantitiesDialogVisibility(bool state)
{
        if ( state )
                m_componentQuantitiesDlg.ShowWindow(SW_SHOW);
        else
                m_componentQuantitiesDlg.ShowWindow(SW_HIDE);
}

We can then call this function using the checkbox handler when we want to show or hide the dialog:

../../_images/idd_dlg_qty.png

As we don’t want to display the dialog immediately we create it, ensure that the Visible property is set to false in the dialog properties window.

Storing the BOM data

The purpose of the dialog is to display selected objects by quantity, assign a unit cost, and then calculate a total. If this is all we wanted to do, the list control alone might be enough to store the data we want. However, we want to do a little more and therefore create an application object to store quantity information. The list control will just be our view of this data.

First let’s create a class representing a single line of the table. The main elements are a Component, representing on instance of the Component class we are counting, some information about the component, a unit cost, total cost and ComponentPathArray called _componentGroup.

This latter value is used to store the paths to all the instances of the component we counted. The size of this array gives us a count of the number of instances. Its useful to store these paths for additional functionality such as highlighting.

// Utility class to store quantity information
struct IFCQuantityRecord
{
        Component *_pcomponent;
        UTF8      _componentName;
        UTF8      _componentType;
        ComponentPathArray _componentGroup; // used for selection
        float     _unitCost;
        float     _totalCost;

        IFCQuantityRecord(Component *pcomp, UTF8 &name, UTF8 &type,  ComponentPathArray &group)
        {
                _pcomponent = pcomp;
                _componentName = name;
                _componentType = type;
                _componentGroup = group;
                _unitCost = 0;
                _totalCost = 0;

        }

        void SetUnitCost(float cost) { _unitCost = cost; }
        void SetTotalCost(float cost) { _totalCost = cost; }
        int  GetComponentQuantity() { return (int) _componentGroup.size();  }
};

We also create a class to represent a list of IFCQuantityRecords, called IFCQuantity and add an instance of this class to the dialog:

class IFCQuantity
{
        std::vector<IFCQuantityRecord> _componentQuantities;

        public:

         IFCQuantity() {};
        ~IFCQuantity() {};

        std::vector<IFCQuantityRecord> &GetQuantities();

        IFCQuantityRecord &  AddQuantityRecord(Component *pcomp, UTF8 &name, UTF8 &type,  ComponentPathArray &group);

        void  ClearAll();
};

std::vector<IFCQuantityRecord>& IFCQuantity::GetQuantities()
{
        return _componentQuantities;
}

IFCQuantityRecord & IFCQuantity::AddQuantityRecord(Component *pcomp, UTF8 & name, UTF8 & type, ComponentPathArray & group)
{
        _componentQuantities.push_back(IFCQuantityRecord( pcomp, name, type,  group ));
        return _componentQuantities.back();
}

void IFCQuantity::ClearAll()
{
        _componentQuantities.clear();
}

Adding objects to the table

At this stage, we now have a dialog, a grid to display data and an object to hold quantity information. Let’s start adding some data to the table.

We are going to use the model browser again. In this case, we will add a context menu item to the tree control, so that when a user selects a particular item, it will be added to the bill of materials.

As we did with the component properties pane, we will use a Windows message to indicate that an item has been selected. In the resource editor, add a new menu item to the context menu as shown:

../../_images/add_to_bom.png

Add a handler to the tree control for this menu item.

../../_images/add_command.png
ON_COMMAND(ID_MB_CONTEXT_ADDTOBOM, &ModelTreeCtrl::OnMbContextAddToBOM)

void ModelTreeCtrl:: OnMbContextAddToBOM()
{

        HPS::Component const & leaf_component = contextItem->GetPath().Front();
        CHPSFrame* pFrame = (CHPSFrame*)(AfxGetApp()->m_pMainWnd);
        pFrame->TagComponent(leaf_component);
        AfxGetApp()->m_pMainWnd->PostMessageW(WM_MFC_SANDBOX_COMPONENT_QUANTIFY,0, 0);
}

When the user selects the item, we store the selected component in the CHPSFrame and then send a message to the main frame. This frame handles the actual functionality.

In CHPSFrame, we must define a message handler for this message. To do that, first we need to add a custom message ID to our application. In CHPSApp.h add the following:

#define WM_MFC_SANDBOX_COMPONENT_QUANTIFY (WM_USER + 105)

Now, define a custom message handler for the frame as shown below. The frame then calls a new function in our dialog to add this component.

afx_msg LRESULT         OnComponentQuantify(WPARAM w, LPARAM l);
ON_MESSAGE(WM_MFC_SANDBOX_COMPONENT_QUANTIFY, &CHPSFrame::OnComponentQuantify)

LRESULT CHPSFrame::OnComponentQuantify(WPARAM/* w*/, LPARAM /*l*/)
{
        m_componentQuantitiesDlg.AddComponent(&m_TaggedComponent);
        return 0;
}

Searching the IFC tree for quantities

We now have the UI components to list a BOM, and select an item from the tree browser to add an entry to the BOM. Lets look at the code to generate the entry from our component selection. We’re going to do this in a new function called HPSComponentQuantitiesDlg::AddComponent().

This function has two parts. The first part searches for instances of the object to add to the BOM, the second part updates the BOM:

bool CHPSComponentQuantitiesDlg::AddComponent( HPS::Component *pComponent)
{
        CHPSDoc * pDoc = static_cast<CHPSDoc *>(static_cast<CFrameWnd *>(AfxGetApp()->m_pMainWnd)->GetActiveDocument());
        HPS::CADModel model = pDoc->GetCADModel();

        HPS::ComponentPathArray compPaths;
        HPS::ComponentArray     ancestors;
        HPS::UTF8 strName = pComponent->GetName();
        HPS::Metadata typeData = pComponent->GetMetadata("TYPE");

        if (typeData.Empty())
                return false;

        HPS::UTF8 strType = IFCUtils::GetMetadataValueAsUTF8(typeData);

        IFCUtils::FindIFCComponentByTypeAndName( model, strType, strName,ancestors,compPaths );

As you can see, using functions we’ve come across before, we get the type and name of the selected component, then call a function to get all the instances of this component class.

Lets take a look at the code to search for the components. This is a generalization of the approach we took to identifying components of type IFCSTOREY.

void  IFCUtils::FindIFCComponentByTypeAndName(Component component, UTF8 &strType, UTF8 &strName, HPS::ComponentArray &ancestorcomponents, HPS::ComponentPathArray &componentPaths)
{
        HPS::Component::ComponentType cType = component.GetComponentType();

        if (cType != HPS::Component::ComponentType::ExchangeProductOccurrence && cType != HPS::Component::ComponentType::ExchangeModelFile)
                return;

        ancestorcomponents.push_back(component);

        if (cType == HPS::Component::ComponentType::ExchangeProductOccurrence)
        {
                UTF8 strLocalType;
                if (GetMetaDataType(component, strLocalType))
                {
                        bool bMatched = false;
                        if (strType == strLocalType)
                        {
                                if (strName == "*")
                                        bMatched = true;
                                else
                                {
                                        HPS::UTF8 strCompName = component.GetName();
                                        bMatched = (strCompName == strName);
                                }
                                if (bMatched)
                                {
                                        ComponentArray reverseComponents(ancestorcomponents);
                                        std::reverse(reverseComponents.begin(), reverseComponents.end());
                                        componentPaths.push_back(ComponentPath(reverseComponents));
                                        ancestorcomponents.pop_back();
                                        return;
                                }
                        }
                }
        }

        HPS::ComponentArray carray = component.GetSubcomponents();
        for (auto comp : carray)
        {
                FindIFCComponentByTypeAndName(comp, strType, strName, ancestorcomponents, componentPaths);
        }

        ancestorcomponents.pop_back();
}

Updating the table

In the second part of our AddComponent function, we add the results of our search to both the IFCQuantity record and table.

Adding entries to both is straightforward. There is just one thing to note in the way we use this list view. You’ll notice from the first image of our control, that the last line of the report is always a total cost for all entries. So we don’t want to append a new entry to the end of the report. Instead, we overwrite the line with the total and then create a new total line.

CString strCnt;
strCnt.Format(_T("%d"), compPaths.size());
UTF8 ustrCnt(strCnt.GetString());

IFCQuantityRecord &qr = mpQuantities->AddQuantityRecord(pComponent, strName, strType, compPaths);

int nIndex = mListQuantities.GetItemCount() - 1; // overwrite current total

mListQuantities.SetItemText(nIndex, 0, _T("0"));
mListQuantities.SetItemText(nIndex, 1, CString(strName));
mListQuantities.SetItemText(nIndex, 2, CString(strType));
mListQuantities.SetItemText(nIndex, 3, strCnt);

mListQuantities.SetRedraw(FALSE);

CHeaderCtrl* pHeader = (CHeaderCtrl*)mListQuantities.GetDlgItem(0);
int nColumnCount = pHeader->GetItemCount();

for (int i = 0; i < nColumnCount; i++)
{
        mListQuantities.SetColumnWidth(i, LVSCW_AUTOSIZE);
        int nColumnWidth = mListQuantities.GetColumnWidth(i);
        mListQuantities.SetColumnWidth(i, LVSCW_AUTOSIZE_USEHEADER);
        int nHeaderWidth = mListQuantities.GetColumnWidth(i);
        mListQuantities.SetColumnWidth(i, std::max(nColumnWidth, nHeaderWidth));
}

UpdateRowCosts( nIndex, qr._unitCost );
UpdateTotal(true);  // add new total row

mListQuantities.SetRedraw(TRUE);

return false;

UpdateRowCosts()

This function simply fills in the final column by taking the number of items for the current row and multiplying that by the unit cost.

void CHPSComponentQuantitiesDlg::UpdateRowCosts(int iRow, float unitCost)
{
        IFCQuantityRecord &qr = mpQuantities->GetQuantities().at(iRow);
        qr.SetUnitCost(unitCost);
        qr._totalCost = unitCost * qr._componentGroup.size();
        CString strTotal;
        strTotal.Format(_T("$%3.2f"), qr._totalCost);

        mListQuantities.SetItemText(iRow, 4, strTotal);
}

UpdateTotal()

This function calculates a total value for all items.

void CHPSComponentQuantitiesDlg::UpdateTotal(bool bInsertNew )
{
        int nIndex = mListQuantities.GetItemCount();

        if (bInsertNew)
                nIndex = mListQuantities.InsertItem(nIndex, _T("")); // new item
        else
                nIndex--; // index of last item in current list

        float totalCost = 0;
        for (auto qr : mpQuantities->GetQuantities())
        {
                totalCost += qr._totalCost;
        }

        CString strTotal;
        strTotal.Format(_T("$%3.2f"), totalCost);

        mListQuantities.SetItemText(nIndex, 0, _T(""));
        mListQuantities.SetItemText(nIndex, 1, _T("Total"));
        mListQuantities.SetItemText(nIndex, 2, _T(""));
        mListQuantities.SetItemText(nIndex, 3, _T(""));
        mListQuantities.SetItemText(nIndex, 4, strTotal);
}

Putting all this functionality together, we now have a modeless dialog, which shows a bill of materials. When the user selects an item in the model browser tree, that item will be added to the BOM and the total cost updated.

You may have noticed the default cost for unit cost for items is 0. This is obviously not realistic, so we need to allow the user to enter the unit cost for an item. That is where we return to the “Edit Label” property we introduced previously.

Editing the table values

Earlier, we set up the list view so that the label is editable. In particular, we set up the unit cost column to be editable. To activate editing of this value, click twice on the field.

../../_images/event_handler_wizard.png

Once the value has been changed we want to respond to this action. We can do this by handling the LVN_ENDLABELEDIT notification. Using the context menu, select “Add Event Handler” and add a handler for this message to the dialog. The code for the handler is shown below:

void CHPSComponentQuantitiesDlg::OnLvnEndlabeleditListQuantity(NMHDR *pNMHDR, LRESULT *pResult)
{
        NMLVDISPINFO *pDispInfo = reinterpret_cast<NMLVDISPINFO*>(pNMHDR);
        // TODO: Add your control notification handler code here

        if (NULL != pDispInfo->item.pszText)
        {
                // Check the new string is a number
                LPTSTR endPtr;

                float fValue    = _tcstof(pDispInfo->item.pszText, &endPtr);
                size_t nChar    = _tcslen(pDispInfo->item.pszText);
                size_t  nParsed = endPtr - pDispInfo->item.pszText;

                if (nChar == nParsed)
                {
                        int iRow = pDispInfo->item.iItem;
                        mListQuantities.SetItemText(iRow, 0, pDispInfo->item.pszText);
                        UpdateRowCosts(iRow, fValue );
                        UpdateTotal(false);
                }
        }
        *pResult = 0;
}

Now, when the “Unit Cost” field is edited, both the line total and grand total will be updated.