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)
If ncWord.Value = vbLf Then
CreateGcodeBlock()
ElseIf MatchIsComment(ncWord) Then
mTotalLines += ncWord.Value.Split(CChar(vbLf)).Length - 1
ElseIf mCurMachine.BlockSkip.Contains(ncWord.Value.Chars(0)) Then
mTotalLines += 1
Else
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.
Public Shared MotionBlocks As New List(Of clsMotionRecord)
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
If Me.Contains(x1, y1) Or Me.Contains(x2, y2) Then
Return True
End If
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
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
iptX = Me.Left
iptY = (slope * iptX) + Yintercept
If iptY > Me.Bottom And iptY < Me.Top Then
Return True
End If
iptX = Me.Right
If iptY > Me.Bottom And iptY < Me.Top Then
Return True
End If
iptY = Me.Top
iptX = ((iptY - Yintercept) / slope)
If iptX > Me.Left And iptX < Me.Right Then
Return True
End If
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
mContext = BufferedGraphicsManager.Current
mContext.MaximumBuffer = New Size(Me.Width + 1, Me.Height + 1)
mGfxBuff = mContext.Allocate(CreateGraphics(),
New Rectangle(0, 0, Me.Width, Me.Height))
mGfx = mGfxBuff.Graphics
End Sub
Private Sub DrawSelectionOverlay()
mGfxBuff.Render()
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#