Create a FeaturePython object part II: Difference between revisions

From FreeCAD Documentation
(Revising...)
(Revising...)
Line 118: Line 118:
obj = App.ActiveDocument.addObject('Part::FeaturePython', obj_name)
obj = App.ActiveDocument.addObject('Part::FeaturePython', obj_name)


fpo = box(obj)
box(obj)


App.ActiveDocument.recompute()
App.ActiveDocument.recompute()


return fpo
return obj


class box():
class box():

Revision as of 13:55, 5 June 2020

This documentation is a work in progress. Please don't mark it as translatable since it will change in the next hours and days.

App::FeaturePython vs. Part::FeaturePython

Up to this point, we've focused on the internal aspects of a Python class built around a FeaturePython object, specifically an App::FeaturePython object. We've created the object, defined some properties, and added a document-level event callback that allows our object to respond to a document recompute with the execute() method.

We still don't have a box, but we can easily create one. First, add a new import to the top of the box.py file: import Part. Then in execute() delete the print() statement and add the following line:

Part.show(Part.makeBox(obj.Length, obj.Width, obj.Height))

These commands execute Python scripts that come with FreeCAD by default:

  • The makeBox() method generates a new box Shape.
  • The enclosing call to Part.show() adds the Shape to the document and makes it visible.

If you have FreeCAD open, reload the box module and create a new box object using box.create() (delete any existing box objects, just to keep things clean).

Notice how a box immediately appears on the screen. that's because the execute method is called immediately after the box is created, because we force the document to recompute at the end of box.create()

But there's a problem.

It should be pretty obvious. The box itself is represented by an entirely different object than our FeaturePython object. The reason is because Part.show() creates a separate box object and adds it to the document. In fact, if you go to your FeaturePython object and change the dimensions, you'll see another box shape gets created and the old one is left in place. That's not good! Additionally, if you have the Report View open, you may notice an error stating "Recursive calling of recompute for document Unnamed". This has to do with using the Part.show() method inside a FeaturePython object. We want to avoid doing that.

Tip

The problem is, we're relying on the Part.make*() methods, which only generate non-parametric Part::Feature objects (simple, dumb shapes), just as you'd get if you copied a parametric object using Part Simple Copy.

What we want, of course, is a parametric box object that resizes the existing box as we change it's properties. We could delete the previous Part::Feature object and regenerate it every time we change a property, but we still have two objects to manage - our custom FeaturePython object and the Part::Feature object it generates.

So how do we solve this problem?

First, we need to use the right type of object.

To this point we've been using App::FeaturePython objects. They're great, but they're not intended for use as geometry objects. Rather, they are better used as document objects which do not require a visual representation in the 3D view. So we need to use a Part::FeaturePython object instead.

In create(), change the following line:

obj = App.ActiveDocument.addObject('App::FeaturePython', obj_name)

to read:

obj = App.ActiveDocument.addObject('Part::FeaturePython', obj_name)

To finish making the changes we need, the following line in the execute() method needs to be changed:

Part.show(Part.makeBox(obj.Length, obj.Width, obj.Height))

to:

obj.Shape = Part.makeBox(obj.Length, obj.Width, obj.Height)

Your code should look like this:

import FreeCAD as App
import Part

def create(obj_name):
    """
    Object creation method
    """

    obj = App.ActiveDocument.addObject('Part::FeaturePython', obj_name)

    box(obj)

    App.ActiveDocument.recompute()

    return obj

class box():

    def __init__(self, obj):
        """
        Default constructor
        """

        self.Type = 'box'

        obj.Proxy = self

        obj.addProperty('App::PropertyString', 'Description', 'Base', 'Box description').Description = ""
        obj.addProperty('App::PropertyLength', 'Length', 'Dimensions', 'Box length').Length = 10.0
        obj.addProperty('App::PropertyLength', 'Width', 'Dimensions', 'Box width').Width = '10 mm'
        obj.addProperty('App::PropertyLength', 'Height', 'Dimensions', 'Box height').Height = '1 cm'

    def execute(self, obj):
        """
        Called on document recompute
        """

        obj.Shape = Part.makeBox(obj.Length, obj.Width, obj.Height)

Now, save your changes and switch back to FreeCAD. Delete any existing objects, reload the box module, and create a new box object.

The results may seem a bit mixed. The icon in the treeview is different - it's a box, now. But there's no cube. And the icon is still gray!

What happened? Although we've properly created our box shape and assigned it to a Part::FeaturePython object, before we can make it show up in our 3D view, we need to assign a ViewProvider.

Writing a ViewProvider

A View Provider is the component of an object which allows it to have visual representation in the GUI - specifically in the 3D view. FreeCAD uses an application structure known as 'model-view', which is designed to separate the data (the 'model') from it's visual representation (the 'view'). If you've spent any time working with FreeCAD in Python, you'll likely already be aware of this through the use of two core Python modules: FreeCAD and FreeCADGui (often aliased as 'App' and 'Gui' repectively).

Thus, our FeaturePython Box implementation also requires these elements. Thus far, we've focused purely on the 'model' portion, so now it's time to write the 'view'. Fortunately, most view implementations are simple and require little effort to write, at least to get started. Here's an example ViewProvider, borrowed and slightly modified from [1]

class ViewProviderBox:

    def __init__(self, obj):
        """
        Set this object to the proxy object of the actual view provider
        """

        obj.Proxy = self

    def attach(self, obj):
        """
        Setup the scene sub-graph of the view provider, this method is mandatory
        """
        return

    def updateData(self, fp, prop):
        """
        If a property of the handled feature has changed we have the chance to handle this here
        """
        return

    def getDisplayModes(self,obj):
        """
        Return a list of display modes.
        """
        return None

    def getDefaultDisplayMode(self):
        """
        Return the name of the default display mode. It must be defined in getDisplayModes.
        """
        return "Shaded"

    def setDisplayMode(self,mode):
        """
        Map the display mode defined in attach with those defined in getDisplayModes.
        Since they have the same names nothing needs to be done.
        This method is optional.
        """
        return mode

    def onChanged(self, vp, prop):
        """
        Print the name of the property that has changed
        """

        App.Console.PrintMessage("Change property: " + str(prop) + "\n")

    def getIcon(self):
        """
        Return the icon in XMP format which will appear in the tree view. This method is optional and if not defined a default icon is shown.
        """

        return """
            /* XPM */
            static const char * ViewProviderBox_xpm[] = {
            "16 16 6 1",
            "    c None",
            ".   c #141010",
            "+   c #615BD2",
            "@   c #C39D55",
            "#   c #000000",
            "$   c #57C355",
            "        ........",
            "   ......++..+..",
            "   .@@@@.++..++.",
            "   .@@@@.++..++.",
            "   .@@  .++++++.",
            "  ..@@  .++..++.",
            "###@@@@ .++..++.",
            "##$.@@$#.++++++.",
            "#$#$.$$$........",
            "#$$#######      ",
            "#$$#$$$$$#      ",
            "#$$#$$$$$#      ",
            "#$$#$$$$$#      ",
            " #$#$$$$$#      ",
            "  ##$$$$$#      ",
            "   #######      "};
            """

    def __getstate__(self):
        """
        Called during document saving.
        """
        return None

    def __setstate__(self,state):
        """
        Called during document restore.
        """
        return None

Note in the above code, we also define an XMP icon for this object. Icon design is beyond the scope of this tutorial, but basic icon designs can be managed using open source tools like GIMP, Krita, and Inkscape. The getIcon method is optional, as well. If it is not provided, FreeCAD will provide a default icon.


With out ViewProvider defined, we now need to put it to use to give our object the gift of visualization.

Return to the create() method in your code and add the following near the end:

ViewProviderBox(obj.ViewObject)

This instances the custom ViewProvider class and passes the FeaturePython's built-in ViewObject to it. The ViewObject won't do anything without our custom class implementation, so when the ViewProvider class initializes, it saves a reference to itself in the FeaturePython's ViewObject.Proxy attribute. This way, when FreeCAD needs to render our Box visually, it can find the ViewProvider class to do that.

Now, save the changes and return to FreeCAD. Import or reload the Box module and call box.create().

We still don't see anything, but notice what happened to the icon next to the box object. It's in color! And it has a shape! That's a clue that our ViewProvider is working as expected.

So now, it's time to actually *add* a box.

Return to the code and add the following line to the execute() method:

obj.Shape = Part.makeBox(obj.Length, obj.Width, obj.Height)

Now save, reload the box module in FreeCAD and create a new box.

You should see a box appear on screen! If it's too big (or maybe doesn't show up at all), click one of the 'ViewFit' buttons to fit it to the 3D view.

Note what we did here that differs from the way it was implemented for App::FeaturePython above. In the execute() method, we called the Part.makeBox() macro and passed it the box properties as before. But rather than call Part.show() on the resulting object (which created a new, separate box object), we simply assigned it to the Shape property of our Part::FeaturePython object instead.

You can even alter the box dimensions by changing the values in the FreeCAD property panel. Give it a try!

Trapping events

To this point, we haven't explicitly addressed event trapping. Nearly every method of a FeaturePython class serves as a callback accessible to the FeaturePython object (which gets access to our class instance through the Proxy attribute, if you recall).

Below is a list of the callbacks that may be implemented in the basic FeaturePython object:

execute(self, obj) Called during document recomputes Do not call recompute() from this method (or any method called from execute()) as this causes a nested recompute.
onBeforeChanged(self, obj, prop) Called before a property value is changed prop is the name of the property to be changed, not the property object itself. Property changes cannot be cancelled. Previous / next property values are not simultaneously available for comparison.
onChanged(self, obj, prop) Called after a property is changed prop is the name of the property to be changed, not the property object itself.
onDocumentRestored(self, obj) Called after a document is restored or aFeaturePython object is copied / duplicated. Occasionally, references to the FeaturePython object from the class, or the class from the FeaturePython object may be broken, as the class __init__() method is not called when the object is reconstructed. Adding self.Object = obj or obj.Proxy = self often solves these issues.

In addition, there are two callbacks in the ViewProvider class that may occasionally prove useful:

updateData(self, obj, prop) Called after a data (model) property is changed obj is a reference to the FeaturePython class instance, not the ViewProvider instance. prop is the name of the property to be changed, not the property object itself.
onChanged(self, vobj, prop) Called after a view property is changed vobj is a reference to the ViewProvider instance. prop is the name of the view property which was changed.

It is not uncommon to encounter a situation where the Python callbacks are not being triggered as they should. Beginners in this area need to rest assured that the FeaturePython callback system is not fragile or broken. Invariably, when callbacks fail to run, it is because a reference is lost or undefined in the underlying code. If, however, callbacks appear to be breaking with no explanation, providing object / proxy references in the onDocumentRestored() callback (as noted in the first table above) may alleviate these problems. Until you are comfortable with the callback system, it may be useful to add print statements in each callback to print messages to the console as a diagnostic during development.

Complete code

import FreeCAD as App
import Part

def create(obj_name):
    """
    Object creation method
    """

    obj = App.ActiveDocument.addObject('Part::FeaturePython', obj_name)

    fpo = box(obj)
    
    ViewProviderBox(obj.ViewObject)

    App.ActiveDocument.recompute()

    return fpo

class box():

    def __init__(self, obj):
        """
        Default constructor
        """

        self.Type = 'box'

        obj.Proxy = self

        obj.addProperty('App::PropertyString', 'Description', 'Base', 'Box description').Description = ""
        obj.addProperty('App::PropertyLength', 'Length', 'Dimensions', 'Box length').Length = 10.0
        obj.addProperty('App::PropertyLength', 'Width', 'Dimensions', 'Box width').Width = '10 mm'
        obj.addProperty('App::PropertyLength', 'Height', 'Dimensions', 'Box height').Height = '1 cm'

    def execute(self, obj):
        """
        Called on document recompute
        """

        obj.Shape = Part.makeBox(obj.Length, obj.Width, obj.Height)

class ViewProviderBox:

    def __init__(self, obj):
        """
        Set this object to the proxy object of the actual view provider
        """
        
        obj.Proxy = self

    def attach(self, obj):
        """
        Setup the scene sub-graph of the view provider, this method is mandatory
        """
        return

    def updateData(self, fp, prop):
        """
        If a property of the handled feature has changed we have the chance to handle this here
        """
        return

    def getDisplayModes(self,obj):
        """
        Return a list of display modes.
        """
        return None

    def getDefaultDisplayMode(self):
        """
        Return the name of the default display mode. It must be defined in getDisplayModes.
        """
        return "Shaded"

    def setDisplayMode(self,mode):
        """
        Map the display mode defined in attach with those defined in getDisplayModes.
        Since they have the same names nothing needs to be done.
        This method is optional.
        """
        return mode

    def onChanged(self, vp, prop):
        """
        Print the name of the property that has changed
        """

        App.Console.PrintMessage("Change property: " + str(prop) + "\n")

    def getIcon(self):
        """
        Return the icon in XMP format which will appear in the tree view. This method is optional and if not defined a default icon is shown.
        """

        return """
            /* XPM */
            static const char * ViewProviderBox_xpm[] = {
            "16 16 6 1",
            "    c None",
            ".   c #141010",
            "+   c #615BD2",
            "@   c #C39D55",
            "#   c #000000",
            "$   c #57C355",
            "        ........",
            "   ......++..+..",
            "   .@@@@.++..++.",
            "   .@@@@.++..++.",
            "   .@@  .++++++.",
            "  ..@@  .++..++.",
            "###@@@@ .++..++.",
            "##$.@@$#.++++++.",
            "#$#$.$$$........",
            "#$$#######      ",
            "#$$#$$$$$#      ",
            "#$$#$$$$$#      ",
            "#$$#$$$$$#      ",
            " #$#$$$$$#      ",
            "  ##$$$$$#      ",
            "   #######      "};
            """

    def __getstate__(self):
        """
        Called during document saving.
        """
        return None

    def __setstate__(self,state):
        """
        Called during document restore.
        """
        return None