Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

CNC Graphical Backplotter

0.00/5 (No votes)
30 Nov 2008 2  
Article and source code for creating a CNC graphical backplotter
Sample image

Introduction

This program will process and graphically display CNC code. This plain text code is used by machine tools to cut metal and other materials. Wikipedia has information on CNC if you are not familiar with it. A CNC Programmer would use this type of program to verify code (G-code) before sending it to a machine to cut a part.

Background

My big plan was to simply use the Upgrade Wizard on an old CNC viewer program that I had written in VB6 and then "Give it away give it away give it away now" when I was finished. It ended up being an exercise in GDI+ and a total rewrite of the parser using Regular Expressions. It's part of a larger project that I am currently rewriting in VB.NET 2005.

These were my requirements for this project.

  • Faster G-code parsing. Regular Expressions helps here.
  • Multiple viewports. 1,2 and 4. A custom User Control was key.
  • 3-D view manipulation. A nimble display list is created with a geometry transformation.
  • Tool filtering to hide and show specific tools.
  • Graphical selection or picking. Combination of a back-buffer and real-time drawing was the answer.

Using the Code

Parsing

G-code is fairly simple. It's a plain ASCII text file made up of lines or "blocks" as they are sometimes called. Each line has a number of words that begin with a letter and is always followed by a number. For example; G00 X1.0. CNC programs can have callable procedures as well. They are commonly used for a set of hole locations that will be used for a number of tools. These procedures or "sub programs" have a specific beginning and end label.

Regular expressions seemed perfect for the job of breaking the file into programs, breaking the programs into blocks and then finally breaking the blocks into individual words. The .NET implementation of Regular Expressions also has an option to be compiled. This helps us get the best performance if the match expression does not change very often.
Because it's not unusual for some CNC programs to exceed 100,000 lines of code, performance is important.

Regular Expressions helped me get the time down from "Go get a coffee" to about 10 seconds on my 97,000-line dataset.

The first match expression that matches programs looks like this. [:\$O]+([0-9]+) It identifies a program label by matching a :, a$ or the Letter O. The \ is needed to escape the $ so RegEx doesn't get confused. The string is concatenated at run time using labels that are specific to each machine tool as shown.

mRegSubs = New Regex(comment & "[" & progId & "]([0-9]+)", 
                     RegexOptions.Compiled) 

As you can see you simply add RegexOptions.Compiled as an optional parameter.
Because g-code supports human readable comments within the code a little extra work is needed.

Comments are usually between parentheses. For example. (REMOVE CLAMP) The comments can also span multiple lines. So the expression match needed to distinguish between comments and actual G-code words. Not a big deal. I used Expresso to help me nail it down.

This match is a little more complicated and looks like this.

\([^\(\)]*\)|[/\*].*\n|\n|[A-Z][-+]?[0-9]*\.?[0-9]*

The first part of the match will match a comment or a newline. The second part will match a letter followed by a number.
In code, the match string is constructed based on what the user specifies as a comment character.

Dim progId As String = Regex.Escape(mCurMachine.ProgramId) 
Dim comment As String = comments(0) & "[^" & comments(0) &_
                        comments(1) & "]*" & comments(1) & "|" 
 
mRegWords = New Regex(comment & "[" & skipChars & "].*\n|\n|" &_
                      My.Resources.datRegexNcWords, _
                      RegexOptions.Compiled Or RegexOptions.IgnoreCase) 

Once the match strings are set we parse the file.

For Each m As Match In Me.mRegSubs.Matches(sFileContents)
    ...
Next

Each program match is then processed for word matching.

For Each p In mNcProgs
    mTotalBites = p.Contents.Length
    ProcessSubWords(p)
Next

Matches are either a newline, a comment or a word.

Private Sub ProcessSubWords(ByVal p As clsProg)
    For Each ncWord As Match In Me.mRegWords.Matches(p.Contents)
        'Each word
        If ncWord.Value = vbLf Then 'Is this a newline
            CreateGcodeBlock()
        ElseIf MatchIsComment(ncWord) Then
            'Comment
            mTotalLines += ncWord.Value.Split(CChar(vbLf)).Length - 1
        ElseIf mCurMachine.BlockSkip.Contains(ncWord.Value.Chars(0)) Then
            'Blockskip.
            mTotalLines += 1
        Else
            'Word
             EvaluateWord()
        End If
    Next
End Sub

Then we evaluate each word appropriately based on the letter or label value.

    Private Sub EvaluateWord()
        Select Case mCurAddress.Label
            Case "X"c
                mXpos = FormatAxis(mCurAddress.StringValue, 
                                   mCurMachine.Precision)
            Case "Y"c
                mYpos = FormatAxis(mCurAddress.StringValue, 
                                   mCurMachine.Precision)
            Case "Z"c
            ...

When a newline is matched we add a record to a collection. This is similar to the way the CNC machine will process the g-code. This collection will be shared amongst all the instances of the viewer control and will be used to create viewer-specific display lists.

'Results of the parsing stored here 
Public Shared MotionBlocks As New List(Of clsMotionRecord) 
'Each viewer control will have it's own version of this list 
Private mDisplayLists As New List(Of clsDisplayList) 

Processing and Rendering

GDI+ is quite rich but as the namespace System.Drawing.Drawing2D suggests its 2D only. It will draw just about anything and allow you to transform it using a 3 x 3 matrix. The approach I used was to create a master list (MotionBlocks) that is shared among all the viewports. Then create a slimmer display list (clsDisplayList)that has had the coordinates rotated in 3-D space.

This transformation is done any time the view orientation is changed. The 3-D rotation code is not fancy but it gets the job done. Look for Private Sub DrawEachElmt() in MG_BasicViewer.vb for details.

The way that machine tools actually move when in rapid positioning mode is also considered. if the machine is at X0,Y0,Z0 and rapids to X1.0 Y2.0 Z3.0 it does not actually move in one linear path. It will move in a dog-leg type motion by completing the shortest axis distance first.

If you download and play with the program you will also notice that it displays a coordinate system indicator at X,Y,Z zero, and that it never changes size. This is done by using an exclusive matrix for the coordinate system that maintains a constant scale factor.

Arcs and circles are another thing that GDI+ will draw just fine but if you need to rotate the arc in 3D space you're out of luck. All the arcs and circles in this program are drawn using line segments that resemble arcs. See the PolyCircle sub for details. You would think this would be wicked slow but it works fine and actually has a couple of advantages.

One of them is that a helix is easy to do if you draw it as line segments. CNC machines often cut a circle while moving in the Z axis to form a helix. Milling screw threads is an example of this.

The other advantage is that you have control over the quality of the arc by drawing more or less line legments.

If you have a large number of arcs that are small on the screen you can draw them with a small number of segments to save rendering time.

The fact that everything is drawn as a line segment also helps when we need to find the extents of the geometry at a particular view orientation. We just rip through all the end points in the display list and set the max and min values.

For Each l As clsDisplayList In mDisplayLists
    For Each p As PointF In l.Points
        mExtentX(0) = Math.Min(mExtentX(0), p.X)
        mExtentX(1) = Math.Max(mExtentX(1), p.X)
        mExtentY(0) = Math.Min(mExtentY(0), p.Y)
        mExtentY(1) = Math.Max(mExtentY(1), p.Y)
    Next
Next

Hit Testing

Selection was another important feature I wanted. Basically, if the mouse is near enough to a line that we have previously drawn, it should be identified by drawing it in a thicker pen. The following is a fragment from the custom rectangle class that can determine if a line passes through it. I was pleasantly surprised with the performance of the code.

    Public Function Contains(ByVal x As Single, ByVal y As Single) _
                                                              As Boolean
        Return x > Left And x < Right And y > Bottom And y < Top
    End Function

    Public Function IntersectsLine(ByVal x1 As Single, ByVal y1 As Single,_
                       ByVal x2 As Single, ByVal y2 As Single) As Boolean
        'Trivial test inside
        If Me.Contains(x1, y1) Or Me.Contains(x2, y2) Then
            Return True
        End If
        'Trivial test outside
        If x1 < Me.Left And x2 < Me.Left Then
            Return False
        ElseIf x1 > Me.Right And x2 > Me.Right Then
            Return False
        ElseIf y1 < Me.Bottom And y2 < Me.Bottom Then
            Return False
        ElseIf y1 > Me.Top And y2 > Me.Top Then
            Return False
        End If

        'Trivial test vertical or horizontal
        If x1 = x2 Then
            Return True
        End If
        If y1 = y2 Then
            Return True
        End If

        Dim slope As Single = (y2 - y1) / (x2 - x1)
        Dim Yintercept As Single = y1 - (slope * x1)
        Dim iptX As Single
        Dim iptY As Single

        'Left edge
        iptX = Me.Left
        iptY = (slope * iptX) + Yintercept
        If iptY > Me.Bottom And iptY < Me.Top Then
            Return True
        End If

        'Right edge
        iptX = Me.Right
        If iptY > Me.Bottom And iptY < Me.Top Then
            Return True
        End If

        'Top edge
        iptY = Me.Top
        iptX = ((iptY - Yintercept) / slope)
        If iptX > Me.Left And iptX < Me.Right Then
            Return True
        End If

        'Bottom edge
        iptY = Me.Bottom
        iptX = ((iptY - Yintercept) / slope)
        If iptX > Me.Left And iptX < Me.Right Then
            Return True
        End If
        Return False
    End Function

Rendering

Once the hit-test code was written things looked pretty good. On a small CNC program that is. It's always good to test your program on large datasets periodically. And this test failed badly. If the entire display list is enumerated every time the mouse is moved things slow down. All we need to do is just draw a few lines in a thicker pen.

The solution was to first draw all the graphics to a buffer, and then in the Paint event of the control simply draw that buffer, which is very fast. Then draw any selected geometry in real-time on top of the buffer.

When the mouse is in selection mode, the graphics never change position so there is no need to re-create the entire display list. So as the mouse moves we draw only the items in the display list that are in the hit zone after we draw the buffer.

DrawSelectionOverlay is simply called from the MouseMove event when in selection mode. The BufferedGraphics class was used to implement my custom back buffer.

 Private Sub SetBufferContext()
    If mGfxBuff IsNot Nothing Then
        mGfxBuff.Dispose()
        mGfxBuff = Nothing
    End If
    ' Retrieves the BufferedGraphicsContext for the current application 
    ' domain.
    mContext = BufferedGraphicsManager.Current
    ' Sets the maximum size for the primary graphics buffer
    mContext.MaximumBuffer = New Size(Me.Width + 1, Me.Height + 1)
    ' Allocates a graphics buffer the size of this control
    mGfxBuff = mContext.Allocate(CreateGraphics(), 
                                 New Rectangle(0, 0, Me.Width, Me.Height))
    mGfx = mGfxBuff.Graphics
End Sub        
        
 Private Sub DrawSelectionOverlay()
    'Draw the buffer
    mGfxBuff.Render()
    
    'Draw the selection overlay.
    mCurPen.Width = ((1 / mDpiX) / mScaleToReal) * 2
    With Graphics.FromHwnd(Me.Handle)
        .PageUnit = GraphicsUnit.Inch
        .ResetTransform()
        .MultiplyTransform(mMtxDraw)
        For Each p As clsDisplayList In mSelectionHitLists
            mCurPen.Color = p.Color
            If p.Rapid Then
                mCurPen.DashStyle = Drawing2D.DashStyle.Dash
            Else
                mCurPen.DashStyle = Drawing2D.DashStyle.Solid
            End If
            .DrawLines(mCurPen, p.Points)
        Next
    End With
End Sub

Points of Interest

The code that does the 3D transformations is old and certainly OpenGL and DirectX are better solutions. Subsequently, I didn't spend any time optimizing it. The whole project was done in "Git-r-done" mode because a more advanced viewer is currently being written using OpenGL and C++.

The selection performance was a big surprise. When there are so many lines on the screen that you can barely see the background color and it's fast, I'm happy. I thought this level of performance was only possible using OpenGL.

Another thing I ran into that I have seen before is a condition where a line does not render completely. A gap can be seen. This will happen when the end points of the line are way off the screen and the line is at a very slight angle. I ended up testing for this condition and making corrections for it. If anyone knows why this happens and how to prevent it please let me know.

You will find some other tricks in the code to boost performance. For example, a test is done to determine if the line is completely off the screen. If it is then it is not drawn into the buffer. It takes way less time to do the test than to draw into the buffer. So the closer you zoom into a large dataset the faster the view manipulation gets.

Also the System.Drawing.Drawing2D.GraphicsPath class looked like it was made for this type of project but it proved to be slow on large datasets. As you can see in the above fragment, the DrawLines method which accepts an array of points was my choice.

You won't find too much error handling in his demo. I stripped it out to keep it simple. I also removed the entire machine configuration form.

History

This is my second article on The Code Project and I hope it's of some value to others.
And of course I welcome suggestions and criticisms.

  • 31st January, 2007: Initial post
  • 28th November, 2008: Source code updated to include C#

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here