3D design with Python and FreeCAD

3D CAD packages can be hard work; there is a lot to learn, which can be a major problem for an infrequent user such as myself.

Most packages support some form of scripting, so why not program my complete design from scratch, without touching the GUI? FreeCAD is a (free) 3D design package, with a comprehensive Python interface, so seems to be ideal…

freecad5

This is simple in theory, but a bit tricky in practice; I’ll spare you the many frustrating false-starts I’ve made, and describe some simple ways of producing 3D objects from scratch in Python. This is very much a work-in-progress, but hopefully will provide some useful pointers if you’re a Python programmer doing occasional 3D design.

The examples here have been tested with FreeCAD v0.16, and the current version 0.18

Running Python code

There are several ways of running a Python script in FreeCAD:

1. Entering commands at the console

In the FreeCAD Python console window, try entering:

FreeCAD.newDocument("Unnamed")
import Part
box = Part.makeBox(4, 3, 2)
Part.show(box)

The result is a bit underwhelming; all you can see is the bottom left-hand corner of a square. If you want to see the box in its full 3-D glory, either use the GUI controls to change the viewpoint, or add the following 2 lines:

FreeCADGui.activeDocument().activeView().viewAxonometric()
FreeCADGui.SendMsgToActiveView("ViewFit")
2. Executing as a Macro

Click on Macro then Macros… and you are given a list of Python macro files that can be executed. They are stored in the default location for scripts; you can alter this to a directory of your choosing, by changing the ‘user macros location’ in the dialog box. If you make that change, it is necessary to exit & re-enter FreeCAD for the change to take effect.

If FreeCAD encounters a problem with your script, it will generally give a sensible error message, however very occasionally a script can corrupt the internals of the program, so it fails to respond in the usual manner. Hence, if you are experiencing problems with previously-good code that suddenly doesn’t work, it is worth restarting FreeCAD in case this fixes the problem.

3. Using the FreeCAD editor

You can load a Python script into FreeCAD using the normal GUI File Open. You are then presented with a nice-looking editor window, into which you can paste one of the examples from this blog. When complete, the file can be run by pressing ctrl-F6. Unfortunately, there are some subtle differences when executing a file in this manner, as opposed to the other methods, see my usage of the recompute() function in the later code examples.

4. Executing a file from the FreeCAD Python console

You can directly execute a file by entering a command at the Python console, e.g.

exec(open("/Projects/FreeCAD/test.py").read())

 

Design methodology

Like many 3D CAD packages, FreeCAD uses the Constructive Solid Geometry (CSG) method, where the final design is built up by adding (fusing) elements together, and subtracting (cutting) one element from another. Simple 3D  objects (cube, cylinder etc.) can be created with a single line of code:

# Simple test of FreeCAD Part scripting, from iosoft.blog

from FreeCAD import Vector
import Part

if FreeCAD.ActiveDocument:
    FreeCAD.closeDocument("Unnamed")
doc = FreeCAD.newDocument("Unnamed")
plate = Part.makeBox(50, 30, 2)
verticals = [edge for edge in plate.Edges if edge.BoundBox.ZLength]
plate = plate.makeFillet(5, verticals)
cyl = Part.makeCylinder(10, 2, Vector(20, 15, 0))
plate = plate.cut(cyl)
doc.addObject("Part::Feature", "plate").Shape = plate
FreeCADGui.activeDocument().activeView().viewAxonometric()
FreeCADGui.SendMsgToActiveView("ViewFit")

The result is a plate with rounded corners, and a large off-centre hole:

freecad1

However the code is more than a single line, so some explanations are in order:

Lines 6-8 remove a previous unnamed document, and create a new one. This means that every time you run the script you get a new clean document to display the result. As a safeguard, if you currently have a named document open, the script will error out

Line 9 creates a square plate, size 50 x 30 units, and 2 units thick.

Lines 10 & 11 create the rounded corners (‘fillets’ in FreeCAD terminology), with a radius of 5 units. The makeFillet method requires a list of edges, and we only want to modify the vertical edges, so the list of edges is filtered by checking the z-dimension length is non-zero.

Lines 12 & 13 create a cylinder with 10 units radius and 2 units high, offset from the origin using a ‘Vector’ object, which defines a position in 3-dimensional (x,y,z) space. The plate is then cut with the cylinder, creating a hole 20 units in diameter.

Line 14 adds the resulting object to the current document; without this step, the object won’t be visible.

Lines 15 & 16 set a perspective view, and adjust the zoom level so the part fits in the display space.

The ‘labels and attributes’ on the left-hand side of the screen show there is only one defined object, named ‘plate’; if you click on that you can modify its placement (i.e. its position and orientation) but none of the other design parameters.

Workbenches

FreeCAD has various sets of software tools, divided up into ‘workbenches’. This division isn’t just for the GUI, it also applies to scripting; for example, the above Python code used tools imported from the Part workbench. There is some overlap between workbenches, so there can be other ways of creating the same object, for example starting with 2-dimensional sketches in the Draft workbench:


# Simple test of FreeCAD Draft scripting, from iosoft.blog

from FreeCAD import Vector
import Draft

if FreeCAD.ActiveDocument:
    FreeCAD.closeDocument("Unnamed")
doc = FreeCAD.newDocument("Unnamed")

rect = Draft.makeRectangle(50, 30, face=True)
rect.FilletRadius = 5
rect.ViewObject.Visibility = False
plate = doc.addObject("Part::Extrusion", "plate")
plate.Base, plate.Dir = rect, Vector(0, 0, 2)

circ = Draft.makeCircle(10, face=True)
rotation = App.Rotation(Vector(0,0,0), 0)
circ.Placement = App.Placement(Vector(20, 15, 0), rotation)
circ.ViewObject.Visibility = False
cyl = doc.addObject("Part::Extrusion", "cylinder")
cyl.Base, cyl.Dir = circ, Vector(0, 0, 2)

FreeCADGui.activeDocument().activeView().viewAxonometric()
FreeCADGui.SendMsgToActiveView("ViewFit")

cutplate = doc.addObject("Part::Cut", "cutplate")
cutplate.Base, cutplate.Tool = plate, cyl

doc.recompute()

To turn the 2-dimensional x-y sketches into 3-dimensional objects, they are extruded in the z-plane. The Draft workbench has no extrusion capability, so this is done by adding extrusion objects to the document. It is important to set the original 2-dimensional sketches as transparent (set visibility false), otherwise they will form a thin layer that obscures the cutout.

I must explain the last 5 lines of code, where I set the viewing mode, cut the plate with the cylinder, then do a recompute. This is a workaround for some minor issues I found in the current FreeCAD versions, which may well be fixed by now:

  • If the view mode is set after doing the cut, you don’t get the correct perspective view.
  • If the final recompute is omitted, everything works fine when executed as a standalone script, but when the same script is executed from the edit screen, the document is blank; none of the objects are visible.

Apart from these issues, the graphical end result looks exactly the same as with the Part workbench, but note the collection of nested objects in the left-hand window. This is CSG in action; the cut plate is derived from a plate and a cylinder, which in turn are derived from a rectangle and circle.

freecad2

An advantage of this object hierarchy is that the design parameters can be changed within the GUI; for example, try changing the circle radius in the attributes window from 10 to 14 units, hit F5 to recompute, and the hole diameter will increase to 28 units. The primary focus of this blog is to use scripting rather than the GUI, but it can be useful to manually change a few parameters, and instantly see the result.

An interesting quirk of this hierarchy is that the cut plate has effectively taken possession of the circle and cylinder, since they are its sub-objects. This raises the question: what happens if we cut 2 plates with the same cylinder, which of the plates will own the cylinder? Let’s try it out:


# Simple test of FreeCAD CSG hierarchy, from iosoft.blog

from FreeCAD import Vector
import Draft

if FreeCAD.ActiveDocument:
    FreeCAD.closeDocument("Unnamed")
doc = FreeCAD.newDocument("Unnamed")

rect = Draft.makeRectangle(50, 30, face=True)
rect.FilletRadius = 5
rect.ViewObject.Visibility = False
plate = doc.addObject("Part::Extrusion", "plate")
plate.Base, plate.Dir = rect, Vector(0, 0, 2)

plate2 = doc.addObject("Part::Extrusion", "plate2")
plate2.Base, plate2.Dir = rect, Vector(0, 0, 2)
rotation = App.Rotation(Vector(0,0,0), 0)
plate2.Placement = App.Placement(Vector(0, 0, 10), rotation)

circ = Draft.makeCircle(10, face=True)
circ.Placement = App.Placement(Vector(20, 15, 0), rotation)
circ.ViewObject.Visibility = False
cyl = doc.addObject("Part::Extrusion", "cylinder")
cyl.Base, cyl.Dir = circ, Vector(0, 0, 12)

FreeCADGui.activeDocument().activeView().viewAxonometric()
FreeCADGui.SendMsgToActiveView("ViewFit")

cutplate = doc.addObject("Part::Cut", "cutplate")
cutplate.Base, cutplate.Tool = plate, cyl

cutplate2 = doc.addObject("Part::Cut", "cutplate2")
cutplate2.Base, cutplate2.Tool = plate2, cyl

doc.recompute()

..and the answer to the question is..

freecad3

FreeCAD v0.18 has duplicated the single cylinder, so it appears twice in the hierarchy. Even though they appear to be separate, these 2 circles & cylinders are actually linked; if you change the radius of one circle, both hole sizes change. The trick is to note that the duplicate objects have the same name; if they were separate items, FreeCAD would have automatically renamed the second one by adding a numeric suffix.

FreeCAD v0.16 does not duplicate the parts, the circle & cylinder only appear once under cutplate2, so caution is needed when working with the older version.

Copyright (c) Jeremy P Bentham 2019. Please credit this blog if you use the information or software in it.

PyQt serial terminal

A simple demonstration of threading in PyQt

I do a lot of work with serial comms; TeraTerm is an excellent serial terminal, but sometimes a customised application is required, for example when dealing with binary data.

The following blog describes a simple Windows & Linux serial terminal, that can be adapted to handle special protocols; it also demonstrates the creation of Python threads, and can serve as a basis for other multi-threaded applications.

pyqt_serialterm1

It won’t win any awards for style, but could come in handy next time you encounter an obscure serial protocol.

Compatibility

My code is compatible with Python 2.7 and 3.x, PyQt v4 and v5, running on Windows or Linux. This necessitates some rather clunky inclusions at the top of the file:

try:
    from PyQt4 import QtGui, QtCore
    from PyQt4.QtGui import QTextEdit, QWidget, QApplication, QVBoxLayout
except:
    from PyQt5 import QtGui, QtCore
    from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
try:
    import Queue
except:
    import queue as Queue

There is also an issue with the different ways Python 2 and 3 handle serial data; older versions assume that the data type is an ASCII string, while in later versions it is a binary type.

This can lead to all sorts of problems (such as unwanted exceptions) so I’ve included specific functions to ensure that all outgoing data is converted from the internal representation (which could be Unicode) to a string of bytes, and incoming data is converted from the external type (data bytes or string) to a string type:

# Convert a string to bytes (for Python 3)
def str_bytes(s):
    return s.encode('latin-1')

# Convert bytes to string (if Python 3)
def bytes_str(d):
    return d if type(d) is str else "".join([chr(b) for b in d])

You’ll need to install pySerial by the usual methods (e.g. ‘pip install’).

Using threading

The key problem with serial communications is the time it takes; the slower the data rate, the longer the delay before anything happens. Without threading (or any other mitigation scheme) the User Interface (UI) will lock up after each command, waiting for the response. At best, this makes the application appear sluggish and unresponsive; at worst, it can appear to have failed, waiting for a response that never comes.

Threading allows the UI to carry on interacting with the user, while simultaneously keeping the serial link alive. Creating a new thread is really easy in Python; you just subclass QThread, and instantiate it:

class SerialThread(QtCore.QThread):
    def __init__(self, portname, baudrate):
        QtCore.QThread.__init__(self)
        self.portname, self.baudrate = portname, baudrate
...
class MyWidget(QWidget):
    ...
    self.serth = SerialThread(portname, baudrate)

I have chosen to supply the serial parameters (port name & baud rate) when the thread object is instantiated, so they are available when the thread is started; this is done by calling the start() method, which will call a run() method in the QThread class:

# In serial thread:
def run(self):
    print("Opening %s at %u baud" % (self.portname, self.baudrate))
...
# In UI thread:
    self.serth.start()

The run() method needs to keep running in a perpetual loop, but it must be possible to terminate that loop when the program exits – Python won’t do it automatically. I use a global variable, that can be set false to terminate, e.g. in pseudocode:

def run(self):
    [starting: open serial port]
    while self.running:
        [check for incoming characters]
        [check for outgoing characters]
    [finished: close serial port]

When the application is closing, it terminates the thread by setting the boolean variable false, then (very importantly) waits for the thread to finish its execution:

def closeEvent(self, event):
    self.serth.running = False
    self.serth.wait()

Transmitting

The user will be entering keystrokes in the UI thread, and it is tempting to call the serial transmit function from that thread, but this isn’t a good idea; it is better to pass the keystrokes across to the serial thread for transmission, and we need a thread-safe method of doing this. That means we can’t just use a global shared string variable; Python does a lot of behind-the-scenes processing that could lead to an unpredictable result. Instead, we’ll use a First In First Out (FIFO) queue:

# In UI thread..
txq = Queue.Queue()
...
txq.put(s)           # Add string to queue
...
# In serial thread..
if not txq.empty():
    txd = txq.get()  # Get string from queue

So the serial thread polls the transmit queue for any data, outputting it to the serial port.

Receiving

We could use the same technique for received data; the serial thread could add it to a queue that is polled by the UI thread, and somehow trigger a UI redraw when the new data arrives, but I prefer to use a signal; the data is attached to that signal, and is received by a UI function that has registered a connection. The signal has to be in a class definition, and must specify the type of data that will be attached:

class MyWidget(QWidget):
    text_update = QtCore.pyqtSignal(str)

The signal is connected to a function that will process the data:

self.text_update.connect(self.append_text)

So now the serial thread just has to generate a signal when new data is received:

self.text_update.emit(text)

This technique would be quite adequate, but I do like having the output from all my ‘print’ function calls redirected to the same window; it makes for cleaner error reporting when things go wrong, rather than having a separate console with error messages. This is done by redirecting stdout to my widget, and adding write() and flush() handlers:

class MyWidget(QWidget):
    text_update = QtCore.pyqtSignal(str)
    def __init__(self, *args): 
        ...
        self.text_update.connect(self.append_text)
        sys.stdout = self
        ...
    def write(self, text):
        self.text_update.emit(text)  
    def flush(self):
        pass
        ...
    def append_text(self, text):        
        [add text to UI display]

So now, every time I make a print() call, a signal is sent to my append_text function, where the display is updated. The use of a signal means that I can still call print() from any thread, without fear of strange cross-threading problems.

Polling in serial thread

The serial thread is just polling for incoming and outgoing characters, and if there are none, the processor will execute the ‘while’ loop really quickly. In the absence of any delays, it will consume a lot of CPU time just checking for things that don’t exist. This may appear harmless, but it is quite alarming for the user if the CPU fan starts spinning rapidly whenever your application is running, and a laptop user won’t be happy if you needlessly drain their battery by performing pointless tasks. So we need to add some harmless time-wasting to the polling loop, by frequently returning control to the operating system. This can be done by calling the ‘sleep’ function, but we still want the software to be responsive when some serial data actually arrives. A suitable compromise is to use a serial read-function with a timeout, so the software ‘blocks’ (i.e. stalls) until either some characters are received, or there is a timeout:

self.ser = serial.Serial(self.portname, self.baudrate, timeout=SER_TIMEOUT)
...
s = self.ser.read(self.ser.in_waiting or 1)

In case you are unfamiliar with the usage, the ‘or’ function returns the left-hand side if it is true (non-zero), otherwise the right-hand side. So every read attempt is at least 1 character, or more characters if they are available. If none are present, the read function waits until the timeout, so when the serial line is idle, most time will be spent waiting in the operating system.

User Interface

This has been kept as simple as possible, with just a text box as a main widget. One minor complication is that as standard, the text box (which is actually a QTextEdit control) will capture and display all keystrokes, so we need to subclass it to intercept the keys, and call a handler function that adds them to the serial transmit queue. I didn’t want to burden the text box with this functionality, so put the handler in its parent, which is the main widget:

class MyTextBox(QTextEdit):
    def __init__(self, *args): 
        QTextEdit.__init__(self, *args)

    def keyPressEvent(self, event):
        self.parent().keypress_handler(event)

The keystroke handler in the main widget gets the character from the key event, and checks for a ctrl-V ‘paste’ request; I’ve included this feature because I find it useful to cut-and-paste frequently-used serial commands from a document, rather than re-typing them every time.

# In main widget:
def keypress_handler(self, event):
    k = event.key()
    s = RETURN_CHAR if k==QtCore.Qt.Key_Return else event.text()
    if len(s)>0 and s[0]==PASTE_CHAR:
        cb = QApplication.clipboard() 
        self.serth.ser_out(cb.text())
    else:
        self.serth.ser_out(s)

Interestingly, with PyQt5 you can cut-and-paste full 8-bit data (i.e. bytes with the high bit set), but this doesn’t seem to work in PyQt4, which only accepts the usual ASCII character set.

I haven’t included any menu options, you have to specify the COM port on the command-line using the -c option, and baud rate using -b. There is also a -x option to display incoming data in hexadecimal, for example:

Windows:
  python pyqt_serialterm.py -c com2 -b 9600 -x
Linux:
  python pyqt_serialterm.py -c /dev/ttyUSB0 -b 115200

On Linux, if you want non-root access to a serial port, you will generally need to add your username to the ‘dialout’ group:

sudo usermod -a -G dialout $USER

..then log out & back in.

Source code

# PyQt serial terminal from iosoft.blog

VERSION = "v0.09"

try:
    from PyQt4 import QtGui, QtCore
    from PyQt4.QtGui import QTextEdit, QWidget, QApplication, QVBoxLayout
except:
    from PyQt5 import QtGui, QtCore
    from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
try:
    import Queue
except:
    import queue as Queue
import sys, time, serial

WIN_WIDTH, WIN_HEIGHT = 684, 400    # Window size
SER_TIMEOUT = 0.1                   # Timeout for serial Rx
RETURN_CHAR = "\n"                  # Char to be sent when Enter key pressed
PASTE_CHAR  = "\x16"                # Ctrl code for clipboard paste
baudrate    = 115200                # Default baud rate
portname    = "COM1"                # Default port name
hexmode     = False                 # Flag to enable hex display

# Convert a string to bytes
def str_bytes(s):
    return s.encode('latin-1')

# Convert bytes to string
def bytes_str(d):
    return d if type(d) is str else "".join([chr(b) for b in d])

# Return hexadecimal values of data
def hexdump(data):
    return " ".join(["%02X" % ord(b) for b in data])

# Return a string with high-bit chars replaced by hex values
def textdump(data):
    return "".join(["[%02X]" % ord(b) if b>'\x7e' else b for b in data])

# Display incoming serial data
def display(s):
    if not hexmode:
        sys.stdout.write(textdump(str(s)))
    else:
        sys.stdout.write(hexdump(s) + ' ')

# Custom text box, catching keystrokes
class MyTextBox(QTextEdit):
    def __init__(self, *args):
        QTextEdit.__init__(self, *args)

    def keyPressEvent(self, event):     # Send keypress to parent's handler
        self.parent().keypress_handler(event)

# Main widget
class MyWidget(QWidget):
    text_update = QtCore.pyqtSignal(str)

    def __init__(self, *args):
        QWidget.__init__(self, *args)
        self.textbox = MyTextBox()              # Create custom text box
        font = QtGui.QFont()
        font.setFamily("Courier New")           # Monospaced font
        font.setPointSize(10)
        self.textbox.setFont(font)
        layout = QVBoxLayout()
        layout.addWidget(self.textbox)
        self.setLayout(layout)
        self.resize(WIN_WIDTH, WIN_HEIGHT)      # Set window size
        self.text_update.connect(self.append_text)      # Connect text update to handler
        sys.stdout = self                               # Redirect sys.stdout to self
        self.serth = SerialThread(portname, baudrate)   # Start serial thread
        self.serth.start()

    def write(self, text):                      # Handle sys.stdout.write: update display
        self.text_update.emit(text)             # Send signal to synchronise call with main thread

    def flush(self):                            # Handle sys.stdout.flush: do nothing
        pass

    def append_text(self, text):                # Text display update handler
        cur = self.textbox.textCursor()
        cur.movePosition(QtGui.QTextCursor.End) # Move cursor to end of text
        s = str(text)
        while s:
            head,sep,s = s.partition("\n")      # Split line at LF
            cur.insertText(head)                # Insert text at cursor
            if sep:                             # New line if LF
                cur.insertBlock()
        self.textbox.setTextCursor(cur)         # Update visible cursor

    def keypress_handler(self, event):          # Handle keypress from text box
        k = event.key()
        s = RETURN_CHAR if k==QtCore.Qt.Key_Return else event.text()
        if len(s)>0 and s[0]==PASTE_CHAR:       # Detect ctrl-V paste
            cb = QApplication.clipboard()
            self.serth.ser_out(cb.text())       # Send paste string to serial driver
        else:
            self.serth.ser_out(s)               # ..or send keystroke

    def closeEvent(self, event):                # Window closing
        self.serth.running = False              # Wait until serial thread terminates
        self.serth.wait()

# Thread to handle incoming & outgoing serial data
class SerialThread(QtCore.QThread):
    def __init__(self, portname, baudrate): # Initialise with serial port details
        QtCore.QThread.__init__(self)
        self.portname, self.baudrate = portname, baudrate
        self.txq = Queue.Queue()
        self.running = True

    def ser_out(self, s):                   # Write outgoing data to serial port if open
        self.txq.put(s)                     # ..using a queue to sync with reader thread

    def ser_in(self, s):                    # Write incoming serial data to screen
        display(s)

    def run(self):                          # Run serial reader thread
        print("Opening %s at %u baud %s" % (self.portname, self.baudrate,
              "(hex display)" if hexmode else ""))
        try:
            self.ser = serial.Serial(self.portname, self.baudrate, timeout=SER_TIMEOUT)
            time.sleep(SER_TIMEOUT*1.2)
            self.ser.flushInput()
        except:
            self.ser = None
        if not self.ser:
            print("Can't open port")
            self.running = False
        while self.running:
            s = self.ser.read(self.ser.in_waiting or 1)
            if s:                                       # Get data from serial port
                self.ser_in(bytes_str(s))               # ..and convert to string
            if not self.txq.empty():
                txd = str(self.txq.get())               # If Tx data in queue, write to serial port
                self.ser.write(str_bytes(txd))
        if self.ser:                                    # Close serial port when thread finished
            self.ser.close()
            self.ser = None

if __name__ == "__main__":
    app = QApplication(sys.argv)
    opt = err = None
    for arg in sys.argv[1:]:                # Process command-line options
        if len(arg)==2 and arg[0]=="-":
            opt = arg.lower()
            if opt == '-x':                 # -X: display incoming data in hex
                hexmode = True
                opt = None
        else:
            if opt == '-b':                 # -B num: baud rate, e.g. '9600'
                try:
                    baudrate = int(arg)
                except:
                    err = "Invalid baudrate '%s'" % arg
            elif opt == '-c':               # -C port: serial port name, e.g. 'COM1'
                portname = arg
    if err:
        print(err)
        sys.exit(1)
    w = MyWidget()
    w.setWindowTitle('PyQT Serial Terminal ' + VERSION)
    w.show()
    sys.exit(app.exec_())

# EOF

 

Copyright (c) Jeremy P Bentham 2019. Please credit this blog if you use the information or software in it.