Tutorial KinematicController

From FreeCAD Documentation
Revision as of 14:55, 22 April 2022 by FBXL5 (talk | contribs) (Created page with "{{Work_in_progress}} <languages/> <translate> {{TutorialInfo |Topic=Kinematic controller from Python |Level=Basic skills of Python are helpful |FCVersion=0.20. and later |Tim...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
This page is a work in progress. Please do not edit or translate until this message is removed.
Other languages:
Tutorial
Topic
Kinematic controller from Python
Level
Basic skills of Python are helpful
Time to complete
(I don't know yet)
Authors
FBXL5
FreeCAD version
0.20. and later
Example files
None
See also
None

Introduction

This tutorial describes how to generate a simple kinematic controller to use with assemblies created with the Assembly3 workbench out of some lines of Python code.

Any text editor can be used to code. My choice is Atom, but FreeCAD's built-in editor works well, too.

The following code examples can be copied and pasted into an empty text file and then saved under a name of your choice as a *.py or *.FCMacro file.

Macro sections

Basic structure of a simple Python macro

#! python
# -*- coding: utf-8 -*-
# (c) 2022 Your name LGPL
def main():
    pass
if __name__ == '__main__':
    # This will be true only if the file is "executed"
    # but not if imported as module
    main()

It consist of a main() routine (function) and a switch checking if the macro is used as a container for classes, methods etc. or if it is run on its own. Only the second option will start the main() routine.

The routine is empty yet and awaits content.


Find driving constraints

The driving constraints are objects within a FreeCAD document. They need to be marked so that they can be found.

For this controller the suffix Driver has to be attached to the label of a driving constraint. It may be separated by a "." or "-" for clarity, but this tool will only search for the last 6 characters of the label.

A routine receiving a document name ad returns a list of driving constraints (the names in this case) will do the job.

def findTheDrivingConstraints(document_name):
    # search through the Objects and find the driving constraint
    driver_list = []
    for each in document_name.Objects:
        if each.Label[-6:] == 'Driver' :
            driving_constraint = each.Name
            driver_list.append(driving_constraint)
    return driver_list

The main() routine loads the name of the active document into the variable kin_doc and then calls the function findTheDrivingConstraints() and hands over the content of kin_doc. The returned list is loaded into drivers which is then checked to contain exactly one item. If that is the case it is finally printed to the report view to check the result.

def main():
    kin_doc = App.ActiveDocument # Kinematic Document
    drivers = findTheDrivingConstraints(kin_doc)
    if len(drivers) < 1:
        print("No driver found!")
    elif len(drivers) > 1:
        print("Not supported yet!")
    else:
        print(drivers)

The macro so far...

#! python
# -*- coding: utf-8 -*-
# (c) 2021 Your name LGPL
def findTheDrivingConstraints(document_name):
    # search through the Objects and find the driving constraint
    driver_list = []
    for each in document_name.Objects:
        if each.Label[-6:] == 'Driver' :
            driving_constraint = each.Name
            driver_list.append(driving_constraint)
    return driver_list

def main():
    kin_doc = App.ActiveDocument # Kinematic Document
    drivers = findTheDrivingConstraints(kin_doc)
    if len(drivers) < 1:
        print("No driver found!")
    elif len(drivers) > 1:
        print("Not supported yet!")
    else:
        print(drivers)
if __name__ == '__main__':
    # This will be true only if the file is "executed"
    # but not if imported as module
    main()

Control panel

The control panel is built from Qt widgets, one main window containing several input/output widgets.

Each widget has to be imported before it can be used, but they can be imported as a single set. And the import line is placed close to the top of the script.

Main window

For the main window an import line looks like this:

from PySide2.QtWidgets import (QDialog)

The main window called ControlPanel is a class object instantiated from the QDialog widget.

It has two init methods. __init__ initialises the new class object, handles incoming arguments, and starts initUI which manages all widgets within the main window.

class ControlPanel(QDialog):
    """
    docstring for ControlPanel.
    """
    def __init__(self, Document, actuator_list):
        super(ControlPanel, self).__init__()
        self.initUI(Document, actuator_list)

    def initUI(self, Document, actuator_list):
        # Setting up class parameters
        # the window has 640 x 480 pixels and is centered by default
        # now make the window visible
        self.show()

To launch the control panel an instance of the new class called form will be created with the document name and the list of driving constraints transferred to this instance. Finally the exec_() method of the class opens the dialog window.

form = ControlPanel(kin_doc, drivers)
form.exec_()

Both lines replace the print command in the else branch of the main section.

Running the macro will display a clean empty dialog window waiting for widgets:

An empty dialog window

And the macro so far...

#! python
# -*- coding: utf-8 -*-
# (c) 2021 Your name LGPL

# imports and constants
from PySide2.QtWidgets import (QDialog)

class ControlPanel(QDialog):
    """
    docstring for ControlPanel.
    """
    def __init__(self, Document, actuator_list):
        super(ControlPanel, self).__init__()
        self.initUI(Document, actuator_list)

    def initUI(self, Document, actuator_list):
        # Setting up class parameters
        # the window has 640 x 480 pixels and is centered by default
        # now make the window visible
        self.show()


def findTheDrivingConstraints(document_name):
    # search through the Objects and find the driving constraint
    driver_list = []
    for each in document_name.Objects:
        if each.Label[-6:] == 'Driver' :
            driving_constraint = each.Name
            driver_list.append(driving_constraint)
    return driver_list

def main():
    kin_doc = App.ActiveDocument # Kinematic Document
    drivers = findTheDrivingConstraints(kin_doc)
    if len(drivers) < 1:
        print("No driver found!")
    elif len(drivers) > 1:
        print("Not supported yet!")
    else:
        form = ControlPanel(kin_doc, drivers)
        form.exec_()
if __name__ == '__main__':
    # This will be true only if the file is "executed"
    # but not if imported as module
    main()

Setting parameters

Now it is time to fill the initUI() section:

...
    def initUI(self, Document, actuator_list):
        # Setting up class parameters
        self.document = Document
        self.actuators = actuator_list
        self.actuator = self.document.getObject(self.actuators[0])
        self.driver_type = self.getDriverType(self.actuator)
        # the window has 640 x 480 pixels and is centered by default
        # now make the window visible
        self.show()
...

The actuator to be used is the first item in the actuators list. (The list contains one single item now, but if the controller can handle more than one driving constraint in the future, it will hold more items.)

Method getDriverType()

For later use we need the driver type (Angle, Distance, Length) and so a method getDriverType() has to be defined:

...
    def getDriverType(self, constraint):
        ANGLE_CONSTRAINTS = [
            "Angle",
            "PlaneCoincident",
            "AxialAlignment",
            "PlaneAlignment"
            ]
        DISTANCE_CONSTRAINTS = [
            "PointDistance",
            "PointsDistance"
            ]
        if constraint.ConstraintType in ANGLE_CONSTRAINTS:
            return "Angle"
        elif constraint.ConstraintType in DISTANCE_CONSTRAINTS:
            return "Distance"
        else:
            return "Length"
...

This method checks if the type of the given constraint can be found in one of the lists to return which kind of dimension has to be controlled.

It is assumed that in the kinematic document the driver is marked correctly and working if edited manually. In this case there is no need to filter out geometric constraints such as Colinear or PointsCoincidence (but here would be the place to do so...)

Window properties

The window size is defined by its minimum and maximum dimensions. Same values mean a fixed size.

The titel shows the driver name and whether its an angle, a distance, or a length. Finally the window is told to stay on top of all windows.

...
        # the window has 640 x 480 pixels and is centered by default
        #- set window dimensions
        self.setMaximumWidth(400)
        self.setMaximumHeight(200)
        self.setMinimumWidth(400)
        self.setMinimumHeight(200)
        self.setWindowTitle(self.actuator.Label + ": " + self.driver_type)
        self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
        # now make the window visible
...

Setting more parameters

Next step is to extract the current value of the driver and set default start and end values depending on the driver type.

A distance cannot be negative and exactly zero puzzles the solver and so the start value is set to 0.001. Angles accept negative values and get symmetric values. (If lengths accept negative values has to be proven finally...)

The unit suffix must be kept for returning the value to the constraint property in the end. Distances and lengths need values with units.

Dealing with units and displaying values as strings in several widgets requires to convert numbers into strings and back again quite often.

To complete the parameters we set a default number of steps that should be computed when the motion is automated and the sequence toggle. I set to TRUE, a picture is taken with each step of the motion.

...
        self.steps_value = 10
        self.sequence = False
        if self.driver_type == "Angle" :
            self.current_value = self.actuator.Angle
            self.start_value = (self.current_value - 15)
            self.end_value = (self.current_value + 15)
            self.unit_suffix = (" °")
        elif self.driver_type == "Distance" :
            self.current_value = float(str(self.actuator.Distance)[:-3])
            self.start_value = 0.001 # Distance must not be <= 0
            self.end_value = (self.current_value + 10)
            self.unit_suffix = (" mm")
        else:
            self.current_value = float(str(self.actuator.Offset)[:-3])
            self.start_value = (self.current_value - 10)
            self.end_value = (self.current_value + 10)
            self.unit_suffix = (" mm")
...
to be continued...