-
Notifications
You must be signed in to change notification settings - Fork 51
Creating custom components
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.
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.
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.
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 toCOLOR_GAMMA
orCOLOR
will be exported as a gamma corrected hex string. -
Arrays that don't specify
unit
andsubtype
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.
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.
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.
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
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.
- Open Blender and create your model, make sure that it is a mesh before exporting it.
- Go to the
Scripting
workspace, create a new file and copy&paste theexport_gizmo.py
that you can find under thescripts
folder. - Click on the
Run script
button and save the file as a[my_gizmo].py
file under thecomponents/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.
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.
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.
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):
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.
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):
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.
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.
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:
- Create a
[component-name].blend
file insidetests/scenes
. The scene should have the new component attached to the corresponding node. - 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"
}
});
});