2
$\begingroup$

As a relative newcomer to python (but not to programming in general) I tried my hands at making custom meshes using blender python templates and tutorials, but now I am stuck at a rather cosmetic issue.

For a specific application, I want to make meshes that contain duplicates of themselves at different scales. Imagine it like a matryoshka doll.

As an example, I have tried to implement the basic functionality I want by building on the "operator_mesh_add.py" template that comes with blender. The code is attached at the end of my question. It should work if you copy it into the blender text editor and run it.

Each of the inner boxes is supposed to have an adjustable relative scale (between 0.0 and 1.0) that is saved in a CollectionProperty. If you try to add a mesh object of this type to the scene, you will see the pop-up UI containing all the properties of the box. "Width", "Height" and "Depth" are simple FloatProperties and can be adjusted here, but the "Planes" CollectionProperty only shows its number of items (which is zero, if you start from default values), but no way to see their scale property or add new ones.

I was able to get around this issue by making a new panel in the "Tool" section of the 3D viewport UI that holds a "Planes" UI list. Adding new planes will give you an adjustable scale for each of them. Once you are happy with the list, you can add a "Scale Box" mesh object which shows the kind of geometry I want, but with no way to adjust its planes through the list after creation. To properly see the result, you should switch to either x-ray or wireframe mode.

I could probably link the behaviour of the created mesh to the list in the panel, but that would be rather awkward, as it would affect every mesh created this way simultaneously. Ideally, I would want the mesh adjustable through a scales list once it has been created, but to remain independent from other mesh objects.

Now as I stated in my header: is there a way to add this functionality to the pop-up UI? Or do I have to find out how to design my own mesh object adder and pop-up from scratch? If so, I would be happy about recommendations for tutorials and examples.

I am guessing that it all hinges on how flexible this method is:

from bpy_extras import object_utils
object_utils.object_data_add(context, mesh, operator=self, name="ScaleBox")

This is my working example:

bl_info = {
    "name" : "Scale Box",
    "author" : "eSemmel",
    "version" : (1, 0),
    "blender" : (2, 93, 4),
    "location" : "View3d > Tool",
    "warning" : "",
    "wiki_url" : "",
    "category" : "Add Mesh",
}

import bpy
import bmesh
from bpy_extras.object_utils import AddObjectHelper

from bpy.props import (
    IntProperty,
    FloatProperty,
    CollectionProperty,
)

# define the container for the scale property
class PlanesClass(bpy.types.PropertyGroup):
    """Contains a plane scale"""
    scale: FloatProperty(
        name="Scale",
        description="Scale of a Box Plane",
        min=0.0, max=1.0,
        default=1.0,
    )

def add_box(width, height, depth, planes):
    """
    This function takes inputs and returns vertex and face arrays.
    no actual mesh data creation is done here.
    """

    core_verts = [
        (+1.0, +1.0, -1.0),
        (+1.0, -1.0, -1.0),
        (-1.0, -1.0, -1.0),
        (-1.0, +1.0, -1.0),
        (+1.0, +1.0, +1.0),
        (+1.0, -1.0, +1.0),
        (-1.0, -1.0, +1.0),
        (-1.0, +1.0, +1.0),
    ]

    core_faces = [
        (0, 1, 2, 3),
        (4, 7, 6, 5),
        (0, 4, 5, 1),
        (1, 5, 6, 2),
        (2, 6, 7, 3),
        (4, 0, 3, 7),
    ]
    
    # get the float values as scales of the inner planes
    scales = [plane.scale for plane in planes]
    # scale 1.0 always exists as the outermost plane and does not have to be specified
    if not scales:
        scales = [1.0]
    elif 1.0 not in scales:
        scales.append(1.0)

    # initialize lists
    n_core = len(core_verts)
    verts = [()] * len(scales) * n_core
    
    # apply size
    for scale_index, scale in enumerate(scales):
        for i, v in enumerate(core_verts):
            verts[i + scale_index * n_core] = v[0] * width * scale, v[1] * depth * scale, v[2] * height * scale
    
    # define faces
    faces = [tuple(face_it + scale_it * n_core for face_it in core_face) for scale_it in range(len(scales)) for core_face in core_faces]
    return verts, faces


class AddBox(bpy.types.Operator, AddObjectHelper):
    """Add a scale box mesh"""
    bl_idname = "mesh.primitive_scale_box_add"
    bl_label = "Add Scale Box"
    bl_options = {'REGISTER', 'UNDO'}

    width: FloatProperty(
        name="Width",
        description="Box Width",
        min=0.01, max=100.0,
        default=1.0,
    )
    height: FloatProperty(
        name="Height",
        description="Box Height",
        min=0.01, max=100.0,
        default=1.0,
    )
    depth: FloatProperty(
        name="Depth",
        description="Box Depth",
        min=0.01, max=100.0,
        default=1.0,
    )
    planes: CollectionProperty(
        type=PlanesClass,
        name="Planes",
        description="Box Planes (scales)",
    )
    
    # fill the planes at creation from the collection saved in the scene
    def invoke(self, context, event):
        self.planes.clear()
        for plane in context.scene.scalebox_planes:
            new_plane = self.planes.add()
            new_plane.scale = plane.scale
        return self.execute(context)

    def execute(self, context):

        verts_loc, faces = add_box(
            self.width,
            self.height,
            self.depth,
            self.planes,
        )

        mesh = bpy.data.meshes.new("ScaleBox")

        bm = bmesh.new()

        for v_co in verts_loc:
            bm.verts.new(v_co)

        bm.verts.ensure_lookup_table()
        for f_idx in faces:
            bm.faces.new([bm.verts[i] for i in f_idx])

        bm.to_mesh(mesh)
        mesh.update()

        # add the mesh as an object into the scene with this utility module
        from bpy_extras import object_utils
        object_utils.object_data_add(context, mesh, operator=self, name="ScaleBox")

        return {'FINISHED'}

# make an add menu entry for the scale box
def menu_func(self, context):
    self.layout.operator(AddBox.bl_idname, icon='MESH_CUBE')

# panel class for adding a scale box
class AddBoxPanel(bpy.types.Panel):
    """Main Panel for adding scale boxes"""
    bl_label = "Scale Box Panel"
    bl_idname = "PT_AddScaleBox"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Tool"
    
    def draw(self, context):
        layout = self.layout
        row = layout.row()
        row.label(text="Options")
        

# panel within panel
class PlanesPanel(bpy.types.Panel):
    """Sub panel for adding planes to the scale box"""
    bl_label = "Planes"
    bl_idname = "PT_AddPlanes"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Tool"
    bl_parent_id = "PT_AddScaleBox"
    bl_options = {'HIDE_HEADER'}
    
    def draw(self, context):
        layout = self.layout
        row = layout.row()
        row.label(text="Planes", icon='FACESEL')
        row = layout.row()
        row.template_list("Planes_UI_list", "", context.scene, "scalebox_planes", context.scene, "scalebox_planes_index")
        row = layout.row()
        row.operator("scalebox_planes.new_item", text="New")
        row = layout.row()
        row.operator("scalebox_planes.delete_item", text="Delete")
        row = layout.row()
        row.operator("mesh.primitive_scale_box_add")


# UI_List
class Planes_UI_list(bpy.types.UIList):
    """UI List for planes scales"""
    def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
        if self.layout_type in {'DEFAULT', 'COMPACT'}:
            layout.label(text="", icon='FACESEL')
            layout.prop(item, "scale")
        elif self.layout_type in {'GRID'}:
            layout.alignment='CENTER'
            layout.label(text="", icon='FACESEL')

class LIST_OT_NewItem(bpy.types.Operator):
    """Add a new plane scale to the list"""
    bl_idname="scalebox_planes.new_item"
    bl_label="Add a new plane scale"
    
    def execute(self, context):
        context.scene.scalebox_planes.add()
        
        return {'FINISHED'}

class LIST_OT_DeleteItem(bpy.types.Operator):
    """Delete a plane scale from the list"""
    bl_idname="scalebox_planes.delete_item"
    bl_label="Delete a plane scale"
    
    @classmethod
    def poll(cls, context):
        return context.scene.scalebox_planes
    
    def execute(self, context):
        scalebox_planes = context.scene.scalebox_planes
        index = context.scene.scalebox_planes_index
        scalebox_planes.remove(index)
        context.scene.scalebox_planes_index = min(max(0, index - 1), len(scalebox_planes) - 1)
        
        return {'FINISHED'}


def register():
    bpy.utils.register_class(PlanesClass)
    bpy.types.Scene.scalebox_planes = CollectionProperty(type=PlanesClass, name="Planes", description="Box Planes (scales)")
    bpy.types.Scene.scalebox_planes_index = IntProperty(name="Planes Index", description="Index of the box plane", default = 0)
    bpy.utils.register_class(Planes_UI_list)
    bpy.utils.register_class(LIST_OT_NewItem)
    bpy.utils.register_class(LIST_OT_DeleteItem)
    bpy.utils.register_class(AddBox)
    bpy.utils.register_class(AddBoxPanel)
    bpy.utils.register_class(PlanesPanel)
    bpy.types.VIEW3D_MT_mesh_add.append(menu_func)
    

def unregister():
    bpy.types.VIEW3D_MT_mesh_add.remove(menu_func)
    bpy.utils.unregister_class(PlanesPanel)
    bpy.utils.unregister_class(AddBoxPanel)
    bpy.utils.unregister_class(AddBox)
    bpy.utils.unregister_class(LIST_OT_DeleteItem)
    bpy.utils.unregister_class(LIST_OT_NewItem)
    bpy.utils.unregister_class(Planes_UI_list)
    bpy.utils.unregister_class(PlanesClass)
    

if __name__ == "__main__":
    register()
$\endgroup$

1 Answer 1

1
$\begingroup$

The redo panel displays only first class properties by default. If you want to display the last item of a CollectionProperty you have to override the draw method of the operator.

enter image description here

    #...

    planes: CollectionProperty(
        type=PlanesClass,
        name="Planes",
        description="Box Planes (scales)",
    )
    
    # fill the planes at creation from the collection saved in the scene
    def invoke(self, context, event):
        self.planes.clear()
        for plane in context.scene.scalebox_planes:
            new_plane = self.planes.add()
            new_plane.scale = plane.scale
        return self.execute(context)

    def draw(self, context):
        layout = self.layout
        row = layout.row()
        row.prop(self, "width")
        row = layout.row()
        row.prop(self.planes[-1], "scale") # Get the latest item
        #...

But this won't update the item of the UIList since operator properties are encapsulated and planes is a new instance of the class. If you want to change both properties at the same time you can implement an update function or use setter/getters to change the property of the UIList too: https://docs.blender.org/api/current/bpy.props.html?highlight=setter#getter-setter-example

Additional Notes

$\endgroup$
1
  • 1
    $\begingroup$ Thank you so much! I was stuck on how to access the pop-up UI to make changes, but if I can simply use the draw function of the object, that solves all the problems and I don't even need the tool panel anymore. Thanks also for the additional links. I will have a read. $\endgroup$
    – eSemmel
    Jan 16, 2022 at 22:53

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .