Skip to content

Creating custom components

Exairnous edited this page Jun 1, 2023 · 8 revisions

In this article we will go over the structure of a Hubs blender add-on component and we will create a simple component as an example.

Creating a new component

We will go over the most important parts but in case you are curious the full Hubs component structure is documented in the HubsComponent definition in the hubs_component.py file.

The first step to create a Hubs component for the Blender add-on is to create a new python source file inside the add-on's components/definitions folder. The file name doesn't really matter as long as it conforms to the python naming conventions. In case your component requires multiple files you can also create a subfolder and add all the component related code under that folder.

Once you have created the python source file you need to create a class that extends from the base HubsComponent class. The HubsComponent class has a base implementation of all the required methods. If your component only exposes some properties you'll probably be good with just inheriting from the base class and adding a component definition. The components registry will traverse the definitions folder and load any classes that inherit from HubsComponent. We will go over the methods that can be extended to extend the component functionality later.

We are going to create an example component to illustrate this process. In our case this is going to be the mirror component so the first step is to create a mirror.py file under the definitions folder.

Adding a component definition

The component needs to define some information:

  • name: It's the name that is used internally in the object's components list and it's also the component name in the GLTF file when the scene is exported. If the name is not set, the component will fallback to the component id which follows the pattern: hubs_component_[Classname]
  • display_name: The name that will be displayed in any component related UI. If no display name is specified, the class name will be used.
  • category: The category the component will fall into in the components list menu. If no category is displayed the component won't show in the components list menu. If your component is a hidden component or it's a dependency of another component that doesn't have a use by itself simply don't add a category. Possible values for the Enum (Category) are [OBJECT, SCENE, ELEMENTS, ANIMATION, AVATAR, MISC, LIGHTS, MEDIA]
  • node_type: The node type where the component will be registered. Possible values for the Enum (NodeType) are [NODE, SCENE, MATERIAL]
  • panel_type: An array of panel types where the component will be shown. Possible values for the Enum (PanelType) are [OBJECT, SCENE, MATERIAL, BONE]
  • deps: An array with all the dependencies that the component requires. All dependencies will be added when the component is added and removed when the component is removed unless some other component requires them. The dependencies are specified by their name property in their definition.
  • icon: The icon to be displayed in the component list for the component. You can use an image (png, jpeg) or a Blender icon string ID. In case you use an icon you will need to add the icon file to the icons folder and set the icon name as the file name. You can find a list of all the available builtin icon IDs here
  • version: The version number for the component. This is checked against the version on already added components and allows them to be migrated to the current version.

In the case of our mirror component, this would look like this:

from ..hubs_component import HubsComponent
from ..types import Category, PanelType, NodeType


class Mirror(HubsComponent):
    _definition = {
        'name': 'mirror',
        'display_name': 'Mirror',
        'category': Category.SCENE,
        'node_type': NodeType.NODE,
        'panel_type': [PanelType.OBJECT],
        'icon': 'MOD_MIRROR',
        'version': (1, 0, 0)
    }

If you run Blender now, you should see the component listed in the components list under the Scene category. At this point that's all, there are no properties or any further functionality but at least the component will be added to the exported GLTF file. You can check that by exporting the scene as a GLTF (GLTF embedded .gltf) and opening it in a text editor. You should see this:

"extensions" : {
    "MOZ_hubs_components" : {
        "mirror" : {}
    }
},

Now we need to add some properties to the component.

Adding component properties

The component properties are just regular Blender API properties, you have a list of all of them here. You can add any type of property that Blender supports.

For our mirror component we will need to expose the tint property. Blender has different property types and some of them have units and subtypes. For showing a Blender color property we need to set it as a FloatVectorProperty property and set the subtype as COLOR_GAMMA.

Our component will then look like this:

from bpy.props import FloatVectorProperty
from ..hubs_component import HubsComponent
from ..types import Category, PanelType, NodeType


class Mirror(HubsComponent):
    _definition = {
        'name': 'mirror',
        'display_name': 'Mirror',
        'category': Category.SCENE,
        'node_type': NodeType.NODE,
        'panel_type': [PanelType.OBJECT, PanelType.BONE],
        'icon': 'MOD_MIRROR',
        'version': (1, 0, 0)
    }

    color: FloatVectorProperty(name="Color",
                               description="Color",
                               subtype='COLOR_GAMMA',
                               default=(0.498039, 0.498039, 0.498039),
                               size=3,
                               min=0,
                               max=1)

If you reload the component you will see that now the component shows a color property that shows the color picked when clicked.

If you export, you should see the serialized color property in the json file:

"extensions" : {
    "MOZ_hubs_components" : {
        "mirror" : {
            "color" : "#7f7f7f"
        }
    }
},

Properties and export

The GLTF exporter handles the Blender properties serialization but the Hubs add-on applies some transforms to certain types to conform to the expected Hubs client input, specifically in the case of arrays. These are the transforms:

  • Arrays with subtype equal to COLOR_GAMMA or COLOR will be exported as a gamma corrected hex string.

  • Arrays that don't specify unit and subtype will be exported as an array.

  • Any other array will be exported as a { x, y, z, w } object depending on the array length.

Properties visibility

The Hubs add-on won't display in the component panel any property that includes a HIDDEN option so if you have an internal property that you don't want exposed, simply add that option to the property's options. If you don't need that property to be persisted in the .blend file when saving, you can also add the SKIP_SAVE option.

Adding logic to the component

So far the component is pretty static, there is no logic at all, we are just showing predefined properties. One simple piece of logic that we can add is showing a warning in case the mirror color is black.

The base add-on implements a draw method. This method is in charge of drawing the content of the component panel once the component is installed. By default this method just looks for the defined component properties and adds them to the panel in a column.

To show the warning we want to override it in our class and add a custom label that will only be displayed in case the color is black.

def draw(self, context, layout, panel_type):
    super().draw(context, layout, panel_type)

    cmp = getattr(context.object, self.get_id())
    if cmp.color[:] == (0.0, 0.0, 0.0):
        layout.label(
            text="You won't see much if the mirror is black", icon='ERROR')

In the the first line we call the base class method as we still want to draw all the component properties (in this case color). Then we get the component property from the current selected object. The base HubsComponent has a method to get the component id. The component id is used by the Hubs add-on internally for the component property when attached to an object. With the line getattr(context.object, self.get_id()) we are getting the component instance that is attached to the current selected object.

Then we just compare if the RGB values of the color property are equal to black and if that's the case we show the warning.

Adding a gizmo to a component

By default the components have no gizmos. To add a gizmo to a component you will need to override the following methods from the base HubsComponent class:

@classmethod
def create_gizmo(cls, ob, gizmo_group):

@classmethod
def update_gizmo(cls, ob, bone, target, gizmo):

There are two alternatives for adding gizmos to a component. A builtin Blender gizmo or a custom model.

Builtin gizmos

Blender has a set of builtin gizmos that you can use right away. There is not much information about the builtin gizmos in the docs right now but you can find a list here

Let's add a simple plane gizmo to our mirror component. First we need to override the create_gizmo method and return our gizmo:

@classmethod
def create_gizmo(cls, ob, gizmo_group):
    gizmo = gizmo_group.gizmos.new('GIZMO_GT_primitive_3d')
    gizmo.draw_style = ('PLANE')
    gizmo.use_draw_scale = False
    gizmo.line_width = 3
    gizmo.color = (0.8, 0.8, 0.8)
    gizmo.alpha = 0.5
    gizmo.hide_select = True
    gizmo.scale_basis = 1.0
    gizmo.use_draw_modal = True
    gizmo.color_highlight = (0.8, 0.8, 0.8)
    gizmo.alpha_highlight = 1.0

    return gizmo

Custom gizmos

In case you need a customized gizmo, you can use your own gizmo model.

Note: Keep in mind that the gizmo models are meant to be light so keep the mesh complexity as low as possible.

  1. Open Blender and create your model, make sure that it is a mesh before exporting it.
  2. Go to the Scripting workspace, create a new file and copy&paste the export_gizmo.py that you can find under the scripts folder.
  3. Click on the Run script button and save the file as a [my_gizmo].py file under the components/models folder.

The create_gizmo method for a custom model is quite similar to the one for builtin gizmos but specifies the custom gizmo class. The Hubs add-on includes a utility CustomModelGizmo class that implements the basic logic for loading a custom gizmo. You can use that one or write your own gizmo class. In this case we are going to use the CustomModelGizmo class.

from ..models import mirror
from ..gizmos import CustomModelGizmo

@classmethod
def create_gizmo(cls, ob, gizmo_group):
    gizmo = gizmo_group.gizmos.new(CustomModelGizmo.bl_idname)
    setattr(gizmo, "hubs_gizmo_shape", mirror.SHAPE)
    gizmo.setup()
    gizmo.use_draw_scale = False
    gizmo.use_draw_modal = True
    gizmo.color = (0.8, 0.8, 0.8)
    gizmo.alpha = 1.0
    gizmo.scale_basis = 1.0
    gizmo.hide_select = False
    gizmo.color_highlight = (0.8, 0.8, 0.8)
    gizmo.alpha_highlight = 0.5

    return gizmo

This is almost the same as before, but you will need to update the hubs_gizmo_shape attribute and set it to your exported shape, and add in a call to gizmo.setup to load it.

Gizmo update

If you open Blender and attach the mirror component to an object and you move the object around, you will see that the gizmo is updated and it follows the object transformation. That's because the default behavior for the update_gizmo method is to copy the object transforms. However, this isn't the same case for bones.

You will also probably notice that the gizmo plane is facing +Z but in Hubs the objects use the -Y axis as the forward facing axis. You also may notice that the gizmo plane is bigger than the standard 1x1m that Hubs uses. We can make the gizmo the correct size, face the right direction, and apply properly to bones, by overriding the update_gizmo method.

from mathutils import Matrix, Quaternion
from math import radians

@classmethod
def update_gizmo(cls, ob, bone, target, gizmo):
    if bone:
        loc, rot, scale = bone.matrix.to_4x4().decompose()
        # Account for bones using Y up
        rot_offset = Matrix.Rotation(radians(-90), 4, 'X').to_4x4()
        rot = rot.normalized().to_matrix().to_4x4() @ rot_offset
        # Account for the armature object's position
        loc = ob.matrix_world @ Matrix.Translation(loc)
        # Apply the custom rotation
        rot_offset = Matrix.Rotation(radians(90), 4, 'X').to_4x4()
        rot = rot @ rot_offset
        # Shrink the gizmo to a 1x1m square (Blender defaults to 2x2m)
        scale = scale / 2
        # Convert the scale to a matrix
        scale = Matrix.Diagonal(scale).to_4x4()
        # Assemble the new matrix
        mat_out = loc @ rot @ scale

    else:
        loc, rot, scale = ob.matrix_world.decompose()
        # Apply the custom rotation
        offset = Quaternion((1.0, 0.0, 0.0), radians(90.0))
        new_rot = rot @ offset
        # Shrink the gizmo to a 1x1m square (Blender defaults to 2x2m)
        scale = scale / 2
        # Assemble the new matrix
        mat_out = Matrix.Translation(
            loc) @ new_rot.normalized().to_matrix().to_4x4() @ Matrix.Diagonal(scale).to_4x4()

    gizmo.matrix_basis = mat_out
    gizmo.hide = not ob.visible_get()

For objects we just need to copy all the object transforms, rotate the gizmo 90 degrees on the X axis so it faces +Y, and scale it down by half. But for bones it's a little more complicated, you have to account for both the object location and the bone location, plus you need to account for bones having their Y and Z axes swapped, then you need to apply any custom transformations, and account for the scale. Now the gizmo should look fine on everything. Note: If you just need support for bones and aren't modifying the orientation or the scale you can simply do this:

@classmethod
def update_gizmo(cls, ob, bone, target, gizmo):
    if bone:
        mat = bone_matrix_world(ob, bone)
    else:
        mat = ob.matrix_world.copy()

    gizmo.hide = not ob.visible_get()
    gizmo.matrix_basis = mat

Now you can export the new component and as long as you have also added support for this component in the Hubs client, you will see it in action. If you have followed this tutorial you should see a mirror object in the Hubs client as that component is already implemented.

What else can I do?

The HubsComponent has some more methods that can be overridden to customize your component behavior. You can take a look at already existing components to see examples of what can be done. Let's take a look at some other methods that you can extend.

Global panel

Some components may require unified settings, or have operators that affect all components of that type, so in order to facilitate this you can add a custom, omnipresent panel to the scene settings via the draw_global method.

@classmethod
def draw_global(cls, context, layout, panel):

Export hooks

def pre_export(self, export_settings, host, ob=None):
def post_export(self, export_settings, host, ob=None):

The pre_export and post_export methods are called before and after the exporter serializes the scene to a GLTF file. A use case for this would be for example applying the object transforms before exporting and restoring them afterwards.

Migration

def migrate(self, migration_type, panel_type, instance_version, host, migration_report, ob=None):

The migrate method is called right after a new .blend file is loaded, the add-on is enabled, something is appended or linked, or the user triggered it manually, so a component can migrate date from previous add-on versions. See hubs_component.py for further information.

If you have removed support for a component on a specific object type and wish to provide a custom unsupported host message you can do so by adding a get_unsupported_host_message method.

@classmethod
def get_unsupported_host_message(cls, panel_type, host, ob=None):

Class registration

def register():
def unregister():

The register/unregister methods are called by the Blender runtime and give the component a chance to register classes that it's going to use.

Component polling

def poll(cls, panel_type, host, ob=None):

The HubsComponent exposes a poll method that is called for every component when the component list is displayed, when the component panel is drawn, and during migration to warn about unsupported hosts. In case of returning True the component will be drawn otherwise it won't. This is the equivalent to the Blender API poll method that you can implement in operators to determine if the execution context is valid.

You can use this if for example a component only makes sense for a specific object type like a Camera. The poll method would look like this in that case:

@classmethod
def poll(cls, panel_type, host, ob=None):
    return host.type == 'CAMERA'

This will only show the component panel in case the object is a Camera.

Adding tests for your component

In case you are planning to contribute your component to the official Hubs Blender add-on repository you will need to provide a unit test. To add a new test you have to:

  1. Create a [component-name].blend file inside tests/scenes. The scene should have the new component attached to the corresponding node.
  2. Add a new test to the test_export.js file.

The test just compares the component GLTF export output with the expected output (preferably using something other than the default values of properties). In the case of our mirror component, and based on a mirror.blend input file, it would look like this:

it('can export mirror', function () {
  let gltfPath = path.resolve(outDirPath, 'mirror.gltf');
  const asset = JSON.parse(fs.readFileSync(gltfPath));

  assert.strictEqual(asset.extensionsUsed.includes('MOZ_hubs_components'), true);
  assert.strictEqual(utils.checkExtensionAdded(asset, 'MOZ_hubs_components'), true);

  const node = asset.nodes[0];
  assert.strictEqual(utils.checkExtensionAdded(node, 'MOZ_hubs_components'), true);

  const ext = node.extensions['MOZ_hubs_components'];
  assert.deepStrictEqual(ext, {
    "mirror": {
      "color": "#ffffff"
    }
  });
});