OverviewFilesCommitsIssuesDocsReleasesPeers

hardops / operators / meshtools / selection_to_boolean_v3.py

612 lines · python ref:
import bpy, bmesh
from mathutils import Matrix, Vector
from enum import Enum
from ... utility import addon, version, geometry_nodes
from ... utility.base_modal_controls import Base_Modal_Controls
from ... utility.collections import view_layer_unhide, hide_all_objects_in_collection, hops_col_get
from ... ui_framework.master import Master
from ... ui_framework.utils.mods_list import get_mods_list
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 ... utility.modifier import user_sort


class State(Enum):
    INSET = 0
    EXTRUDE = 1

    @classmethod
    def states(cls):
        return [cls.INSET, cls.EXTRUDE]


class HOPS_OT_Sel_To_Bool_V3(bpy.types.Operator):
    bl_idname = "hops.sel_to_bool_v3"
    bl_label = "Selection To Boolean V3"
    bl_description = """Selection to Boolean
    Convert active face(s) to boolean
    Press H for help
    """
    bl_options = {"REGISTER", "UNDO", "BLOCKING"}

    override_obj_name: bpy.props.StringProperty(name="Target Override", default="")

    # Popover
    operator = None
    selected_operation = ""

    @classmethod
    def poll(cls, context):
        if context.mode == 'EDIT_MESH':
            if context.active_object and context.active_object.type == 'MESH':
                return True
        return False


    def invoke(self, context, event):
        self.nodes = addon.preference().property.workflow_modifier == 'NODES'

        if self.nodes:
            self.boolean_ops = ['DIFFERENCE', 'UNION', 'KNIFE']

        else:
            self.boolean_ops = ['DIFFERENCE', 'UNION']

        # Override : Called from extract face
        use_override = False
        if self.override_obj_name:
            if self.override_obj_name in bpy.data.objects:
                target_obj = bpy.data.objects[self.override_obj_name]
                if target_obj.name in context.view_layer.objects:
                    use_override = True
                    self.override_setup(context, target_obj)

        if use_override == False:
            # Target
            self.obj = context.active_object
            self.boolean_mod = None

            # Boolean
            self.bool_obj = None
            self.solidify_mod = None
            self.bm = None
            self.bool_mesh_backup = None

            # Setup
            if not self.selection_valid(): return {'CANCELLED'}
            self.standard_setup(context)

        # State
        self.state = State.INSET
        self.apply_mods = False
        self.use_as_boolean = True
        self.og_xray = context.space_data.shading.show_xray
        self.og_shading = context.space_data.shading.type

        # Controls
        self.accumulation = 0
        self.inset_value = 0
        self.extrude_value = 0

        # Base Systems
        self.master = Master(context)
        self.master.only_use_fast_ui = True
        self.base_controls = Base_Modal_Controls(context, event, popover_keys=['TAB', 'SPACE'])
        self.original_tool_shelf, self.original_n_panel = collapse_3D_view_panels()
        self.draw_handle_2D = bpy.types.SpaceView3D.draw_handler_add(self.safe_draw_2D, (context,), 'WINDOW', 'POST_PIXEL')
        context.window_manager.modal_handler_add(self)

        # Popover
        self.__class__.operator = self
        self.__class__.selected_operation = ""

        return {"RUNNING_MODAL"}

    # --- Setup --- #

    def selection_valid(self):
        bm = bmesh.from_edit_mesh(self.obj.data)
        faces = [f for f in bm.faces if f.select]
        if len(faces) > 0: return True
        else: return False


    def override_setup(self, context, target_obj):

        self.obj = target_obj
        self.bool_obj = context.active_object
        self.bool_obj.hops.status = "BOOLSHAPE"

        # Collection
        col = hops_col_get(bpy.context)

        if col and self.bool_obj.users_collection:
            for collection in self.bool_obj.users_collection:
                if collection != col:
                    collection.objects.unlink(self.bool_obj)

        if self.bool_obj.name not in col.objects:
            view_layer_unhide(col, enable=True)
            hide_all_objects_in_collection(coll=col)
            col.objects.link(self.bool_obj)

        self.add_mods(context)
        self.parent_move_display()

        context.view_layer.objects.active = self.bool_obj

        # Edit
        if context.mode != 'EDIT':
            bpy.ops.object.mode_set(mode='EDIT')

        # Clean
        self.bm = bmesh.from_edit_mesh(self.bool_obj.data)
        self.bool_obj.update_from_editmode()

        # Backup
        self.bool_mesh_backup = self.bool_obj.data.copy()


    def standard_setup(self, context):

        # New mesh
        bpy.ops.object.mode_set(mode='OBJECT')
        mesh = self.obj.data.copy()
        self.bool_obj = bpy.data.objects.new(mesh.name, mesh)
        self.bool_obj.hops.status = "BOOLSHAPE"

        # Collection
        col = hops_col_get(bpy.context)

        view_layer_unhide(col, enable=True)
        hide_all_objects_in_collection(coll=col)

        col.objects.link(self.bool_obj)

        self.add_mods(context)
        self.parent_move_display()

        # Edit
        bpy.ops.object.select_all(action='DESELECT')
        self.bool_obj.select_set(True)
        context.view_layer.objects.active = self.bool_obj
        bpy.ops.object.mode_set(mode='EDIT')

        # Clean
        self.bm = bmesh.from_edit_mesh(self.bool_obj.data)
        verts = [v for v in self.bm.verts if not v.select]
        bmesh.ops.delete(self.bm, geom=verts, context='VERTS')
        faces = [f for f in self.bm.faces if not f.select]
        bmesh.ops.delete(self.bm, geom=faces, context='FACES')

        sca = self.obj.matrix_world.decompose()[2]
        bmesh.ops.transform(self.bm, matrix=Matrix.Diagonal(sca), space=Matrix(), verts=self.bm.verts)

        bmesh.update_edit_mesh(self.bool_obj.data)
        self.bool_obj.update_from_editmode()

        # Backup
        self.bool_mesh_backup = self.bool_obj.data.copy()


    def add_mods(self, context):

        # Solidify
        self.solidify_mod = self.bool_obj.modifiers.new('Solidify', type='SOLIDIFY')
        self.solidify_mod.use_even_offset = True
        self.solidify_mod.offset = -.95
        self.solidify_mod.use_quality_normals = True
        self.solidify_mod.show_viewport = False

        # Boolean
        if self.nodes:
            self.boolean_mod = geometry_nodes.NodeBoolean.from_object(self.obj)
            self.boolean_mod.name = 'HOPS Boolean'
        else:
            self.boolean_mod = self.obj.modifiers.new("HOPS Boolean", 'BOOLEAN')
        if hasattr(self.boolean_mod, 'solver'):
            self.boolean_mod.solver = version.bsolver_fast()
        self.boolean_mod.show_render = True
        self.boolean_mod.object = self.bool_obj
        self.boolean_mod.show_viewport = False

        user_sort(self.obj)


    def parent_move_display(self):

        loc, rot, sca = self.obj.matrix_world.decompose()
        mat_loc = Matrix.Translation(loc).to_4x4()
        mat_sca = Matrix.Diagonal(Vector((1,1,1))).to_4x4()
        mat_rot = rot.to_matrix().to_4x4()
        mat_out = mat_loc @ mat_rot @ mat_sca

        self.bool_obj.matrix_world = mat_out
        self.bool_obj.parent = self.obj
        self.bool_obj.matrix_parent_inverse = self.obj.matrix_world.inverted()
        self.bool_obj.display_type = 'WIRE'

    # --- Controler -- #

    def modal(self, context, event):

        # --- Systems --- #
        self.master.receive_event(event)
        self.base_controls.update(context, event)
        mouse_warp(context, event)
        self.accumulation += self.base_controls.mouse

        # --- Controls ---#
        if self.base_controls.pass_through:
            return {'PASS_THROUGH'}

        elif self.base_controls.cancel:
            self.cancelled(context)
            return {'CANCELLED'}

        elif self.base_controls.confirm:
            if self.state == State.EXTRUDE:
                self.confirmed(context)
                return {'FINISHED'}
            else:
                self.state = State.EXTRUDE

        elif self.base_controls.scroll and not event.shift:
            self.cycle_state(forward=bool(self.base_controls.scroll))

        # Popover
        ret = self.popover(context)
        if ret == True: return {'FINISHED'}

        # Cycle
        if event.type == 'X' and event.value == 'PRESS':
            self.cycle_state(forward=event.shift)

        # Toggle Bool
        elif event.type == 'S' and event.value == 'PRESS':
            self.use_as_boolean = not self.use_as_boolean

            if self.use_as_boolean:
                self.boolean_mod.show_viewport = True
                self.boolean_mod.object = self.bool_obj
                self.bool_obj.display_type = 'WIRE'
            else:
                self.boolean_mod.show_viewport = False
                self.boolean_mod.object = None
                self.bool_obj.display_type = 'SOLID'

        # Apply mods on exit / Boolean Sovler
        elif event.type == 'A' and event.value == 'PRESS':
            if event.shift:
                self.apply_mods = not self.apply_mods
            else:
                i = self.boolean_ops.index(self.boolean_mod.operation)
                op = self.boolean_ops[(i+1) % len(self.boolean_ops)]
                self.boolean_mod.operation = op
                bpy.ops.hops.display_notification(info=f"Boolean Operation : {self.boolean_mod.operation}")

        # Flip
        elif event.type == 'F' and event.value == 'PRESS':
            if self.state == State.EXTRUDE:
                self.solidify_mod.offset = self.solidify_mod.offset * -1

        # Toggle X-Ray
        elif event.type == 'Z' and event.value == 'PRESS':
            bpy.context.space_data.shading.show_xray = not bpy.context.space_data.shading.show_xray

        # Solver
        elif event.type == 'E' and event.value == 'PRESS':
            if hasattr(self.boolean_mod, 'solver'):
                solvers = [itm.identifier for itm in bpy.types.BooleanModifier.bl_rna.properties['solver'].enum_items]
                idx = solvers.index(self.boolean_mod.solver)
                self.boolean_mod.solver = solvers[(idx + 1) % len(solvers)]
                bpy.ops.hops.display_notification(info=f"Boolean Solver : {self.boolean_mod.solver}")

        # Move mod
        elif self.base_controls.scroll and event.shift:
            if self.obj and self.boolean_mod:
                    active = context.active_object
                    context.view_layer.objects.active = self.obj
                    if self.base_controls.scroll > 0:
                        bpy.ops.object.modifier_move_up(modifier=self.boolean_mod.name)
                    else:
                        bpy.ops.object.modifier_move_down(modifier=self.boolean_mod.name)
                    context.view_layer.objects.active = active

        # Solidify Offsets
        if self.state == State.EXTRUDE:
            if event.type == 'ONE' and event.value == 'PRESS':
                self.solidify_mod.offset = -.95

            elif event.type == 'TWO' and event.value == 'PRESS':
                self.solidify_mod.offset = 0

            elif event.type == 'THREE' and event.value == 'PRESS':
                self.solidify_mod.offset = .95

            elif event.type == 'FOUR' and event.value == 'PRESS':
                if (2, 82, 4) < bpy.app.version:
                    if self.solidify_mod.solidify_mode == 'EXTRUDE':
                        self.solidify_mod.solidify_mode = 'NON_MANIFOLD'
                    elif self.solidify_mod.solidify_mode == 'NON_MANIFOLD':
                        self.solidify_mod.solidify_mode = 'EXTRUDE'

        # --- Update --- #
        if event.type != 'TIMER':
            self.update_mesh(event)
            self.interface(context)

        context.area.tag_redraw()
        return {"RUNNING_MODAL"}


    def update_mesh(self, event):

        if self.state == State.INSET:
            bmesh.ops.delete(self.bm, geom=self.bm.verts, context='VERTS')
            self.bm.from_mesh(self.bool_mesh_backup)
            self.set_mod_visibility(on=False)
            self.inset_value = self.accumulation
            self.inset()

        elif self.state == State.EXTRUDE:
            self.set_mod_visibility(on=True)
            self.extrude_value = self.accumulation
            self.extrude(event)

        bmesh.update_edit_mesh(self.bool_obj.data)


    def inset(self):
        result = bmesh.ops.inset_region(
            self.bm,
            faces=self.bm.faces,
            use_boundary=True,
            use_even_offset=True,
            use_interpolate=True,
            use_relative_offset=False,
            use_edge_rail=True,
            thickness=self.inset_value,
            depth=0,
            use_outset=False)
        bmesh.ops.delete(self.bm, geom=result['faces'], context='FACES')


    def extrude(self, event):
        if event.alt:
            self.solidify_mod.offset += self.base_controls.mouse
            if self.solidify_mod.offset > 1: self.solidify_mod.offset = 1
            elif self.solidify_mod.offset < -1: self.solidify_mod.offset = -1
        else:
            self.solidify_mod.thickness = self.extrude_value


    def interface(self, context):
        self.master.setup()
        if not self.master.should_build_fast_ui(): return

        win_list = []
        if self.state == State.INSET:
            win_list.append("Mode: Inset")
            win_list.append(f"{self.inset_value:.3f}")

        elif self.state == State.EXTRUDE:
            win_list.append("Mode: Extrude")
            win_list.append(f"{self.extrude_value:.3f}")

        win_list.append(f"Apply: {self.apply_mods}")
        win_list.append(f"Boolean: {self.use_as_boolean}")

        help_items = {"GLOBAL" : [], "STANDARD" : []}
        help_items["GLOBAL"] = [
            ("M", "Toggle mods list"),
            ("H", "Toggle help"),
            ("~", "Toggle UI Display Type"),
            ("O", "Toggle viewport rendering"),
            ("Z", "Toggle Wireframe / Solid")]

        i = self.boolean_ops.index(self.boolean_mod.operation)
        next_bool_op = self.boolean_ops[(i+1) % len(self.boolean_ops)]

        help_items["STANDARD"] = [
            ("ALT"         , "Solidify Offset"),
            ("F"           , "Flip Offset"),
            ("Z"           , "X-Ray View"),
            ("1"           , "Offset to : -1"),
            ("2"           , "Offset to : 0"),
            ("3"           , "Offset to : 1"),
            ("4"           , f"Solidify Mode : {self.solidify_mod.solidify_mode}"),
            ("Shift A"     , "Apply modifiers"),
            ("A"           , f'Use : {next_bool_op}'),
            ("S"           , "Toggle use as bool"),
            ("X"           , "Cycle Operation"),
            ("Scroll"      , "Cycle Operation"),
            ("Shift Scroll", "Move Modifier"),
            ("TAB"         , "Select Menu")]

        if hasattr(self.boolean_mod, 'solver'):
            help_items["STANDARD"].append(("E", f"Solver {self.boolean_mod.solver}"))

        active_mod = self.boolean_mod.name if self.boolean_mod else ""
        self.master.receive_fast_ui(win_list=win_list, help_list=help_items, image="Booleans", mods_list=get_mods_list(mods=self.obj.modifiers), active_mod_name=active_mod)
        self.master.finished()


    def popover(self, context):
        if self.__class__.selected_operation != "":
            if self.__class__.selected_operation == "INSET":
                self.state = State.INSET
                bpy.ops.hops.display_notification(info=f'State Set To : {self.__class__.selected_operation}')
            elif self.__class__.selected_operation == "EXTRUDE":
                self.state = State.EXTRUDE
                bpy.ops.hops.display_notification(info=f'State Set To : {self.__class__.selected_operation}')
            elif self.__class__.selected_operation == "APPLY":
                self.apply_mods = True
                bpy.ops.hops.display_notification(info="Modifiers will be applied on exit.")
            elif self.__class__.selected_operation == "CONFIRM":
                self.confirmed(context)
                return True

            self.__class__.selected_operation = ""

        # Spawns
        if self.base_controls.popover:
            context.window_manager.popover(popup_draw)

        return False

    # --- Utils --- #

    def set_mod_visibility(self, on=True):
        self.solidify_mod.show_viewport = on
        self.boolean_mod.show_viewport = on


    def cycle_state(self, forward=True):
        step = 1 if forward else -1
        types = State.states()
        index = types.index(self.state) + step
        self.state = types[index % len(types)]

    # --- Exit --- #

    def shut_down(self, context):
        self.__class__.operator = None
        self.__class__.selected_operation = ""
        self.override_obj_name = ""
        collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
        self.master.run_fade()
        self.remove_shaders()
        context.space_data.shading.show_xray = self.og_xray
        context.space_data.shading.type = self.og_shading
        bpy.data.meshes.remove(self.bool_mesh_backup)


    def cancelled(self, context):
        self.shut_down(context)
        bpy.ops.object.mode_set(mode='OBJECT')
        mesh = self.bool_obj.data
        bpy.data.objects.remove(self.bool_obj)
        bpy.data.meshes.remove(mesh)

        bpy.ops.object.select_all(action='DESELECT')
        self.obj.select_set(True)
        context.view_layer.objects.active = self.obj
        bpy.ops.object.mode_set(mode='EDIT')

        self.obj.modifiers.remove(self.boolean_mod if not self.nodes else self.boolean_mod.modifier)


    def confirmed(self, context):
        self.shut_down(context)
        bpy.ops.object.mode_set(mode='OBJECT')
        bpy.ops.object.select_all(action='DESELECT')
        self.obj.hide_set(False)
        context.view_layer.objects.active = self.obj
        self.obj.select_set(True)
        bpy.ops.object.mode_set(mode='EDIT')

        # Fallback on errors
        if self.boolean_mod.object == None:
            self.obj.modifiers.remove(self.boolean_mod if not self.nodes else self.boolean_mod.modifier)
            # Unlink
            for coll in self.bool_obj.users_collection:
                coll.objects.unlink(self.bool_obj)
            # Link
            coll = None
            if self.obj.users_collection: coll = self.obj.users_collection[0]
            else: coll = context.collection
            coll.objects.link(self.bool_obj)
            # Apply
            if self.apply_mods:
                bpy.ops.object.mode_set(mode='OBJECT')
                context.view_layer.objects.active = self.bool_obj
                bpy.ops.object.modifier_apply(modifier=self.solidify_mod.name)
                context.view_layer.objects.active = self.obj
                bpy.ops.object.mode_set(mode='EDIT')

        # Show bool in edit mode
        else:
            if hasattr(self.boolean_mod, 'show_in_editmode'):
                self.boolean_mod.show_in_editmode = True
            # Apply
            if self.apply_mods:
                bpy.ops.object.mode_set(mode='OBJECT')
                context.view_layer.objects.active = self.bool_obj
                bpy.ops.object.modifier_apply(modifier=self.solidify_mod.name)
                context.view_layer.objects.active = self.obj
                bpy.ops.object.modifier_apply(modifier=self.boolean_mod.name)

                # Remove bool object
                mesh = self.bool_obj.data
                bpy.data.objects.remove(self.bool_obj)
                bpy.data.meshes.remove(mesh)
                self.bool_obj = None

                bpy.ops.object.mode_set(mode='EDIT')

        # Select bool object
        if not self.apply_mods:
            if self.bool_obj:
                bpy.ops.object.mode_set(mode='OBJECT')
                bpy.ops.object.select_all(action='DESELECT')
                context.view_layer.objects.active = self.bool_obj
                self.bool_obj.select_set(True)

        # Remove custom normals.
        if self.bool_obj:
            context.view_layer.objects.active = self.bool_obj
            bpy.ops.mesh.customdata_custom_splitnormals_clear()

    # --- Shaders --- #

    def remove_shaders(self):
        if self.draw_handle_2D:
            self.draw_handle_2D = bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle_2D, "WINDOW")


    def safe_draw_2D(self, context):
        method_handler(draw_modal_frame, arguments=(context,), identifier='Modal Shader 2D', exit_method=self.remove_shaders)

# --- POPOVER --- #

def popup_draw(self, context):
    layout = self.layout

    op = HOPS_OT_Sel_To_Bool_V3.operator
    if not op: return {'CANCELLED'}

    layout.label(text='Selector')
    broadcaster = "hops.popover_data"

    row = layout.row()
    row.scale_y = 2
    props = row.operator(broadcaster, text='Inset')
    props.calling_ops = 'SELECT_TO_BOOLEAN'
    props.str_1 = 'INSET'

    row = layout.row()
    row.scale_y = 2
    props = row.operator(broadcaster, text='Extrude')
    props.calling_ops = 'SELECT_TO_BOOLEAN'
    props.str_1 = 'EXTRUDE'

    row = layout.row()
    row.scale_y = 2
    props = row.operator(broadcaster, text='Apply')
    props.calling_ops = 'SELECT_TO_BOOLEAN'
    props.str_1 = 'APPLY'

    if op.state == State.EXTRUDE:
        row = layout.row()
        row.scale_y = 2
        props = row.operator(broadcaster, text='Confirm')
        props.calling_ops = 'SELECT_TO_BOOLEAN'
        props.str_1 = 'CONFIRM'
    else:
        row = layout.row()
        row.scale_y = 2
        row.label(text="Extrude before continuing.")