OverviewFilesCommitsIssuesDocsReleasesPeers

hardops / operators / modals / radial_array.py

606 lines · python ref:
import bpy, bmesh, math, enum
from ... utility import addon
from ... utility import modifier, object
from ... utility.base_modal_controls import Base_Modal_Controls
from ... ui_framework.master import Master
from ... ui_framework.utils.mods_list import get_mods_list
from ... utils.objects import apply_scale

# Cursor Warp imports
from ... utils.toggle_view3d_panels import collapse_3D_view_panels
from ... utils.modal_frame_drawing import draw_modal_frame
from ... utils.cursor_warp import mouse_warp
from ... utility import method_handler
from mathutils import Vector, Matrix

class origin_modes(enum.Enum):
    original = 0
    center = 1
    cursor = 2

mode_string = {
    origin_modes.original: "Object Origin",
    origin_modes.center: "Object Center",
    origin_modes.cursor: "3D Cursor",
}

DESC = '''LMB - Array around object origin / Reuse Radial Driven Empty
Ctrl + LMB - Array around 3D cursor
Shift + LMB - Array selected object around active object
'''


class HOPS_OT_RadialArray(bpy.types.Operator):
    bl_idname = "hops.radial_array"
    bl_label = "Radial Array"
    bl_options = {'REGISTER', 'UNDO', 'GRAB_CURSOR'}
    bl_description = DESC

    direction: bpy.props.EnumProperty(
        name="Direction",
        description="What axis to displace on",
        items=[
            ('X', "X", "Displace on the X axis"),
            ('Y', "Y", "Displace on the Y axis"),
            ('Z', "Z", "Displace on the Z axis")],
        default='Y')

    axis: bpy.props.EnumProperty(
        name="Axis",
        description="What axis to array around",
        items=[
            ('X', "X", "Array around the X axis"),
            ('Y', "Y", "Array around the Y axis"),
            ('Z', "Z", "Array around the Z axis")],
        default='Z')

    segments: bpy.props.IntProperty(
        name="Segments",
        description="How many copies to make",
        default=8,
        min=1)

    rotation: bpy.props.IntProperty(
        name="Rotation",
        description="Amount of rotation",
        default=360,
        min=15)

    displace: bpy.props.FloatProperty(
        name="Displace",
        description="How far in or out to displace",
        default=0)

    # cursor: bpy.props.BoolProperty(
    #     name="3D Cursor",
    #     description="Use the 3D cursor as origin",
    #     default=False)

    from_empty: bpy.props.BoolProperty(
        name='From Empty',
        description='Use active Empty object instead of new one',
        default=False)

    @classmethod
    def poll(cls, context):
        obj = context.active_object
        return obj and obj.type in {'MESH', 'EMPTY'} and obj.mode == 'OBJECT'


    def draw(self, context):
        self.layout.label(text=f"Direction: {self.direction}")
        self.layout.label(text=f"Axis: {self.axis}")
        self.layout.label(text=f"Segments: {self.segments}")
        self.layout.label(text=f"Displace: {self.displace:.3f}")
        self.layout.label(text=f"Origin: {mode_string[self.origin_mode]}")


    def execute(self, context):
        return {'FINISHED'}


    def invoke(self, context, event):

        # Set internal variables
        self.modal_scale = addon.preference().ui.Hops_modal_scale
        self.obj = context.active_object

        self.index = 0
        self.obj_matrix = self.obj.matrix_world.copy()
        self.active_matrix = None
        self.to_selection = False
        self.origin_mode = origin_modes.original

        self.selected_states = [(obj, obj.select_get()) for obj in context.selected_objects if obj is not context.active_object and obj.type == 'MESH']

        if not self.from_empty:
            for pair in self.selected_states:
                pair[0].select_set(False)

        if event.shift and not event.ctrl and len(self.selected_states) == 1:
            self.to_selection = True
            self.obj = self.selected_states[0][0]
            self.active_matrix = context.active_object.matrix_world.copy()
            self.obj_matrix = self.obj.matrix_world.copy()

        if self.from_empty:
            if len(self.selected_states) == 1:
                self.to_selection = False
                self.obj = self.selected_states[0][0]
                self.obj_matrix = self.obj.matrix_world.copy()
                empty = context.active_object
                loc = empty.location.copy()
                rot = empty.rotation_euler.copy()
                sca = empty.scale.copy()
                context.active_object.select_set(False)

                try:
                    for drvr in empty.animation_data.drivers:
                        if drvr.data_path == 'location':
                            loc[drvr.array_index] = 0
                        elif drvr.data_path == 'rotation_euler':
                            rot[drvr.array_index] = 0
                        elif drvr.data_path == 'scale':
                            sca[drvr.array_index] = 1
                except:
                    bpy.ops.hops.display_notification(info=F'Failed: Invalid Empty', subtext='Select a HOPS Radial Empty')
                    return {'CANCELLED'}

                empty_mat = Matrix.Translation(loc) @ rot.to_quaternion().to_matrix().to_4x4() @ Matrix.Diagonal((*sca, 1))
                parent_mat = empty.parent.matrix_world @ empty.matrix_parent_inverse if empty.parent else Matrix()
                matrix = parent_mat @ empty_mat
                object.set_origin(self.obj, matrix)

            elif not self.to_selection:
                self.report({'INFO'}, 'Cannot Array an Empty')
                bpy.ops.hops.display_notification(info=F'CANCELLED: Cannot Array an Empty')
                return {'CANCELLED'}


        is_displace = len([mod for mod in self.obj.modifiers if mod.type == 'DISPLACE'])
        if not is_displace:
            scale_vectors = []
            apply_scale([self.obj], scale_vectors=scale_vectors)

        # Set internal variables
        self.empty = None
        self.displace_mod = None
        self.array_mod = None

        # Find empty and modifiers
        for a, b in zip(self.obj.modifiers[:-1], self.obj.modifiers[1:]):
            if a.type == 'DISPLACE' and b.type == 'ARRAY' and b.use_object_offset == True:
                self.empty = b.offset_object
                self.displace_mod = a
                self.array_mod = b
                break

        # Set internal variables
        self.empty_new = not self.empty
        self.displace_new = not self.displace_mod
        self.array_new = not self.array_mod

        if self.from_empty:
            self.empty = context.active_object
            self.empty_new = False

        # Create empty and modifiers
        if self.empty_new:
            self.empty = bpy.data.objects.new("Radial Empty", None)
            self.empty.empty_display_type = 'SPHERE'
            self.empty.parent = self.obj
            for col in self.obj.users_collection:
                if col not in self.empty.users_collection:
                    col.objects.link(self.empty)

        if self.displace_new:
            self.displace_mod = self.obj.modifiers.new("Radial Displace", 'DISPLACE')
            self.displace_mod.show_expanded = False

            self.displace_mod.show_render = addon.preference().property.Hops_twist_radial_sort
            self.displace_mod.show_in_editmode = not addon.preference().property.Hops_twist_radial_sort

            self.displace_mod.mid_level = 0.0
            self.displace_mod.direction = self.direction
            self.displace_mod.strength = self.displace

        if self.array_new:
            self.array_mod = self.obj.modifiers.new("Radial Array", 'ARRAY')
            self.array_mod.show_expanded = False

            self.array_mod.show_render = addon.preference().property.Hops_twist_radial_sort
            self.array_mod.show_in_editmode = not addon.preference().property.Hops_twist_radial_sort

            self.array_mod.use_constant_offset = False
            self.array_mod.use_relative_offset = False
            self.array_mod.use_object_offset = True
            self.array_mod.offset_object = self.empty
            self.array_mod.count = self.segments

        # Set internal variables
        self.direction = self.displace_mod.direction
        self.axis = "YZX"["XYZ".find(self.direction)]
        self.segments = self.segments_buffer = self.array_mod.count
        self.displace = self.displace_buffer = self.displace_mod.strength

        self.reuse_driver = False
        # Try to find the axis from the empty's drivers
        if self.empty.animation_data:
            for fcurve in self.empty.animation_data.drivers:
                if fcurve.data_path == "rotation_euler":
                    self.axis = "XYZ"[fcurve.array_index]
                    self.reuse_driver = self.from_empty

        # Store modifier settings
        self.displace_settings = {}
        self.displace_settings["show_viewport"] = self.displace_mod.show_viewport
        self.displace_settings["mid_level"] = self.displace_mod.mid_level
        self.displace_settings["direction"] = self.displace_mod.direction
        self.displace_settings["strength"] = self.displace_mod.strength

        self.array_settings = {}
        self.array_settings["use_constant_offset"] = self.array_mod.use_constant_offset
        self.array_settings["use_relative_offset"] = self.array_mod.use_relative_offset
        self.array_settings["use_object_offset"] = self.array_mod.use_object_offset
        self.array_settings["offset_object"] = self.array_mod.offset_object
        self.array_settings["count"] = self.array_mod.count

        # Configure existing modifiers
        if not self.displace_new:
            self.displace_mod.mid_level = 0.0
            self.displace_mod.direction = self.direction
            self.displace_mod.strength = self.displace

        if not self.array_new:
            self.array_mod.use_constant_offset = False
            self.array_mod.use_relative_offset = False
            self.array_mod.use_object_offset = True
            self.array_mod.offset_object = self.empty
            self.array_mod.count = self.segments

        # Move object origin
        # self.cursor_prev = bool(self.cursor)
        if event.ctrl and not event.shift and event.type == 'LEFTMOUSE':
            self.origin_mode = origin_modes.cursor

        if self.empty_new:
            self.set_origin(context)
        elif self.origin_outside_bounds(context): #why is this a thing?
            self.origin_mode = origin_modes.cursor

        if self.from_empty:
            self.origin_mode = origin_modes.original
            self.displace_mod.show_viewport = False
            self.array_mod.count = self.shared_avg_segments()

        # Create driver
        self.direction_prev = str(self.direction)
        self.axis_prev = str(self.axis)
        self.create_driver(context, self.rotation)

        # Set internal variables
        self.adjusting = 'NONE' if self.origin_mode == origin_modes.cursor else  'DISPLACE'
        self.displace_center = float(self.displace)
        self.displace_cursor = float(self.displace)
        self.displace_original = float(self.displace)
        self.set_displace(context)

        # Base Systems
        self.master = Master(context=context)
        self.master.only_use_fast_ui = True
        self.base_controls = Base_Modal_Controls(context, event)
        self.original_tool_shelf, self.original_n_panel = collapse_3D_view_panels()
        self.draw_handle = bpy.types.SpaceView3D.draw_handler_add(self.safe_draw_shader, (context,), 'WINDOW', 'POST_PIXEL')

        context.window_manager.modal_handler_add(self)
        return {"RUNNING_MODAL"}


    def modal(self, context, event):

        # Base Systems
        self.master.receive_event(event=event)
        self.base_controls.update(context, event)
        mouse_warp(context, event)

        if self.base_controls.pass_through:
            return {'PASS_THROUGH'}

        elif event.type == 'Z' and (event.shift or event.alt):
            return {'PASS_THROUGH'}

        elif self.base_controls.confirm:
            self.confirm(context)
            self.report({'INFO'}, "Finished")
            self.remove_shader()
            collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
            self.master.run_fade()
            return {'FINISHED'}

        elif self.base_controls.cancel:
            self.cancel(context)
            self.report({'INFO'}, "Cancelled")
            self.remove_shader()
            collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
            self.master.run_fade()
            return {'CANCELLED'}

        elif event.type == 'A' and event.value == 'PRESS':
            self.displace_mod.show_viewport = not self.displace_mod.show_viewport
            self.report({'INFO'}, f"{'Enabled' if self.displace_mod.show_viewport else 'Disabled'} Displacement")

        elif event.type == 'S' and event.value == 'PRESS':
            self.adjusting = 'NONE' if self.adjusting == 'SEGMENTS' else 'SEGMENTS'
            self.report({'INFO'}, f"{'Started' if self.adjusting == 'SEGMENTS' else 'Stopped'} Adjusting Segments")

        elif event.type == 'D' and event.value == 'PRESS':
            self.adjusting = 'NONE' if self.adjusting == 'DISPLACE' else 'DISPLACE'
            self.report({'INFO'}, f"{'Started' if self.adjusting == 'DISPLACE' else 'Stopped'} Adjusting Displace")

        elif event.type == 'Z' and event.value == 'PRESS':
            self.direction = "YZX"["XYZ".find(self.direction)]
            self.displace_mod.direction = self.direction
            self.report({'INFO'}, f"Displace Axis: {self.direction}")

        elif event.type == 'X' and event.value == 'PRESS':
            self.direction = "YZX"["XYZ".find(self.direction)]
            self.displace_mod.direction = self.direction
            self.axis = "YZX"["XYZ".find(self.axis)]
            self.create_driver(context, self.rotation)
            self.report({'INFO'}, f"Rotate Axis: {self.axis}")

        elif event.type == 'R' and event.value == 'PRESS':
            rotations = ['360', '180', '90', '60', '45', '30', '15']
            self.index += 1
            if self.index + 1 == len(rotations):
                self.index = 0
            self.rotation = int(rotations[self.index])
            self.report({'INFO'}, F'Rotation : {self.rotation}')

        elif event.type in {'ZERO', 'NUMPAD_0'} and event.value == "PRESS":
            self.displace_mod.strength = 0
            self.displace_buffer = 0
            bpy.ops.hops.display_notification(info=F'Displace: {self.displace_mod.strength}')

        elif event.type == 'C' and event.value == 'PRESS' and not self.from_empty:
            self.adjusting = 'NONE'
            self.origin_mode = origin_modes((self.origin_mode.value + 1) % len(origin_modes.__members__))
            self.set_displace(context)
            self.set_origin(context)
            self.create_driver(context, self.rotation)
            self.report({'INFO'}, f"Origin: {mode_string[self.origin_mode]}")
            bpy.ops.hops.display_notification(info=F'Origin: {mode_string[self.origin_mode]}')

        elif self.base_controls.scroll and event.shift:
            self.rotation += self.base_controls.scroll
            self.report({'INFO'}, F'Rotation : {self.rotation}')

        elif self.base_controls.scroll:
            self.segments_buffer += self.base_controls.scroll
            self.segments_buffer = max(self.segments_buffer, 1)
            self.segments = round(self.segments_buffer)
            self.array_mod.count = self.segments
            self.report({'INFO'}, f"Segments: {self.segments}")

        elif event.type == 'MOUSEMOVE' and self.adjusting == 'SEGMENTS':
            offset = self.base_controls.mouse
            self.segments_buffer += offset
            self.segments_buffer = max(self.segments_buffer, 1)
            self.segments = round(self.segments_buffer)
            self.array_mod.count = self.segments

        elif event.type == 'MOUSEMOVE' and self.adjusting == 'DISPLACE':
            offset = self.base_controls.mouse * 2.5
            self.displace_buffer -= offset
            digits = 2 if event.ctrl and event.shift else 1 if event.ctrl else 3
            self.displace = round(self.displace_buffer, digits)
            self.displace_mod.strength = self.displace

        self.create_driver(context, self.rotation)
        self.draw_master(context=context)
        context.area.tag_redraw()
        return {"RUNNING_MODAL"}


    def set_displace(self, context):
        if self.origin_mode == origin_modes.cursor:
            self.displace_center = self.displace_original = float(self.displace)
            self.displace_buffer = self.displace_cursor
            self.displace = self.displace_cursor
            self.displace_mod.strength = self.displace_cursor

        elif self.origin_mode == origin_modes.center:
            self.displace_original = self.displace_cursor = float(self.displace)
            self.displace_buffer = self.displace_center
            self.displace = self.displace_center
            self.displace_mod.strength = self.displace_center

        else:
            self.displace_center = self.displace_cursor = float(self.displace)
            self.displace_buffer = self.displace_original
            self.displace = self.displace_original
            self.displace_mod.strength = self.displace_original


    def set_origin(self, context):
        if self.to_selection:
            object.set_origin(self.obj, self.active_matrix)
        elif self.origin_mode == origin_modes.cursor:
            bpy.ops.object.origin_set(type='ORIGIN_CURSOR', center='BOUNDS')
        elif self.origin_mode == origin_modes.center:
            bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
        elif self.origin_mode == origin_modes.original:
            object.set_origin(self.obj, self.obj_matrix)

        if not self.from_empty:
            self.empty.parent = None
            self.empty.parent = self.obj
            self.empty.location = (0,0,0)
            self.empty.rotation_euler = (0,0,0)
            self.empty.scale = (1,1,1)


    def origin_outside_bounds(self, context):
        temp = bpy.data.objects.new("Bounding Box", self.obj.data)
        bb = temp.bound_box[:]
        bpy.data.objects.remove(temp)
        x = bb[6][0] < 0 or bb[0][0] > 0
        y = bb[6][1] < 0 or bb[0][1] > 0
        z = bb[6][2] < 0 or bb[0][2] > 0
        return x or y or z


    def create_driver(self, context, rotation):
        if self.reuse_driver:
            return

        [self.empty.driver_remove("rotation_euler", i) for i in range(3)]
        self.empty.rotation_euler = (0,0,0)
        index = "XYZ".find(self.axis)
        self.driver = self.empty.driver_add("rotation_euler", index).driver
        count = self.driver.variables.new()
        count.name = "count"
        count.targets[0].id = self.obj
        count.targets[0].data_path = f"modifiers[\"{self.array_mod.name}\"].count"
        self.driver.expression = f"{math.radians(self.rotation)} / count"


    def shared_avg_segments(self):
        if not self.empty: return
        divisor = math.degrees(self.empty.rotation_euler.z)
        if divisor <= 0: divisor = 1
        ret_val = 360 / divisor
        return int(ret_val) + 1


    def confirm(self, context):
        modifier.sort(self.obj, types=['WEIGHTED_NORMAL'], last=True)

        if addon.preference().property.workflow == 'DESTRUCTIVE':
            modifier.apply(self.obj, self.displace_mod)
            modifier.apply(self.obj, self.array_mod)
            bpy.data.objects.remove(self.empty)

        self.from_empty = False


    def cancel(self, context):
        self.direction = self.direction_prev
        self.axis = self.axis_prev

        object.set_origin(self.obj, self.obj_matrix)
        if not self.from_empty:
            self.empty.parent = None
            self.empty.parent = self.obj
            self.empty.location = (0,0,0)
            self.empty.rotation_euler = (0,0,0)
            self.empty.scale = (1,1,1)

        if self.displace_new:
            self.obj.modifiers.remove(self.displace_mod)
        else:
            self.displace_mod.show_viewport = self.displace_settings["show_viewport"]
            self.displace_mod.mid_level = self.displace_settings["mid_level"]
            self.displace_mod.direction = self.displace_settings["direction"]
            self.displace_mod.strength = self.displace_settings["strength"]

        if self.array_new:
            self.obj.modifiers.remove(self.array_mod)
        else:
            self.array_mod.use_constant_offset = self.array_settings["use_constant_offset"]
            self.array_mod.use_relative_offset = self.array_settings["use_relative_offset"]
            self.array_mod.use_object_offset = self.array_settings["use_object_offset"]
            self.array_mod.offset_object = self.array_settings["offset_object"]
            self.array_mod.count = self.array_settings["count"]

        if self.empty_new:
            bpy.data.objects.remove(self.empty)

        elif self.from_empty and not self.reuse_driver:
            for i in range(3) : self.empty.driver_remove("rotation_euler", i)

        else:
            self.create_driver(context, self.rotation)

        for obj, sel_state in self.selected_states:
            obj.select_set(sel_state)

        self.from_empty = False


    def draw_master(self, context):

        # Start
        self.master.setup()

        ########################
        #   Fast UI
        ########################

        if self.master.should_build_fast_ui():

            # Main
            win_list = []
            if addon.preference().ui.Hops_modal_fast_ui_loc_options != 1: #Fast Floating
                win_list.append(f"{self.axis}")
                win_list.append(f"{self.segments}")
                win_list.append(f"{self.displace:.2f}")
            else:
                win_list.append("Radial Array")
                win_list.append(f"{self.axis}")
                win_list.append(f"{self.segments}")
                win_list.append(f"{self.displace:.3f}")

            # Help
            help_items = {"GLOBAL" : [], "STANDARD" : []}

            help_items["GLOBAL"] = [
                ("M", "Toggle mods list"),
                ("H", "Toggle help"),
                ("~", "Toggle UI Display Type"),
                ("O", "Toggle viewport rendering")]

            help_items["STANDARD"] = [
                ("Scroll", "Increment Segments"),
                ("Shift + Scroll", f"Adjust Rotation  {self.rotation}°"),
                ("A",      "Toggle Displace"),
                ("0",      "Set Displace To 0"),
                ("S",      "Adjust Segments"),
                ("D",      "Adjust Displace"),
                ("Z",      "Increment Displace Axis"),
                ("R",      "Change radial to 180/90/45/30"),
                ("X",      "Increment Rotate Axis"),
                ("C",      "Array Around Object Origin / 3D Cursor / Object Center")]

            # Mods
            active_mod = ""
            if self.displace_mod != None:
                active_mod = self.displace_mod.name
            mods_list = get_mods_list(mods=bpy.context.active_object.modifiers)

            self.master.receive_fast_ui(win_list=win_list, help_list=help_items, image="ArrayCircle", mods_list=mods_list, active_mod_name=active_mod)

        # Finished
        self.master.finished()

    # --- SHADER --- #

    def safe_draw_shader(self, context):
        method_handler(self.draw_shader,
            arguments = (context,),
            identifier = 'UI Framework',
            exit_method = self.remove_shader)


    def remove_shader(self):
        '''Remove shader handle.'''

        if self.draw_handle:
            self.draw_handle = bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle, "WINDOW")


    def draw_shader(self, context):
        '''Draw shader handle.'''

        draw_modal_frame(context)