OverviewFilesCommitsIssuesDocsReleasesPeers

hardops / operators / modals / face_extract.py

696 lines · python ref:
import re
import bpy, math, gpu, bmesh
from enum import Enum
from mathutils import Vector, Matrix
from gpu_extras.batch import batch_for_shader
from bpy_extras import view3d_utils
from ... utility import addon
from ... utility.base_modal_controls import Base_Modal_Controls
from ... ui_framework.master import Master
from ... utils.toggle_view3d_panels import collapse_3D_view_panels
from ... utility import method_handler
from ... utils.space_3d import ray_cast_objects
from ... ui_framework import form_ui as form


class State(Enum):
    EXTRACT = 0
    BOOLEAN = 1
    SOLIDIFY = 2

    @classmethod
    def states(cls):
        return [cls.EXTRACT, cls.BOOLEAN, cls.SOLIDIFY]


DESC = """Face Extract

Select faces to create interactive boolean
Shift Click - Only creates face duplicate
Ctrl Click - Use decimate on initialize

Press H for help"""


class HOPS_OT_FaceExtract(bpy.types.Operator):
    bl_idname = "view3d.face_extract"
    bl_label = "Face Extract"
    bl_description = DESC
    bl_options = {"REGISTER", "UNDO", "BLOCKING"}

    # Popover
    operator = None
    selected_operation = ""

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


    def invoke(self, context, event):

        # State
        self.state = State.EXTRACT if event.shift else State.BOOLEAN
        self.mouse_down = False
        self.indexes_during_this_mouse_down = []
        self.use_last_bevel = True
        self.use_decimate = True if event.ctrl else False
        self.use_local_view = True
        self.started_in_local = context.space_data.local_view

        # Object
        self.og_object = context.active_object
        self.og_mod_visibility = {m.name : m.show_viewport for m in self.og_object.modifiers}
        self.mesh = None
        self.obj = None
        self.setup_object(context)
        self.face_indexes = []

        # Shader
        built_in_shader = 'UNIFORM_COLOR' if bpy.app.version[0] >=4 else '3D_UNIFORM_COLOR'
        self.shader = gpu.shader.from_builtin(built_in_shader)
        self.line_batch = None
        self.face_batch = None

        # Dot UI
        self.show_form = len(self.og_object.modifiers) > 0
        self.form = None
        self.setup_form(context, event)

        # --- Base Systems --- #
        self.master = Master(context)
        self.master.only_use_fast_ui = True
        self.base_controls = Base_Modal_Controls(context, event, popover_keys=['TAB'])
        self.base_controls.confirm_events = ['SPACE', 'RET', 'NUMPAD_ENTER']
        self.base_controls.pass_through_events = ['MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE']
        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')
        self.draw_handle_3D = bpy.types.SpaceView3D.draw_handler_add(self.safe_draw_3D, (context,), 'WINDOW', 'POST_VIEW')

        context.window_manager.modal_handler_add(self)

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

        return {"RUNNING_MODAL"}


    def modal(self, context, event):

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

        if self.show_form:
            self.form.update(context, event)

        alt_scroll = True if event.type in {'WHEELDOWNMOUSE', 'WHEELUPMOUSE'} and event.alt else False

        # --- Base Controls --- #
        if self.base_controls.pass_through:
            if not self.form.active():
                if not alt_scroll:
                    return {'PASS_THROUGH'}

        elif self.base_controls.cancel:
            return self.cancel_exit(context)

        elif self.base_controls.confirm:
            if not self.form.active():
                return self.confirm_exit(context)

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

        # --- Actions --- #

        scroll_step = self.base_controls.scroll
        if alt_scroll and scroll_step:
            types = State.states()
            index = types.index(self.state) + scroll_step
            self.state = types[index % len(types)]

        if not self.form.active():
            self.actions(context, event)

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


    def actions(self, context, event):

        # Mouse Controls
        if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
            if event.ctrl:
                self.select_shortest_path(context, event)
                return
            else:
                self.mouse_down = True

        elif event.type == 'LEFTMOUSE' and event.value == 'RELEASE':
            self.mouse_down = False
            self.indexes_during_this_mouse_down = []

        self.select_face(context, event)

        # Exit to solidify
        if event.type == 'S' and event.value == "PRESS":
            if self.state == State.SOLIDIFY:
                self.state = State.EXTRACT
            else:
                self.state = State.SOLIDIFY
            bpy.ops.hops.display_notification(info=f"Exit : {self.state.name}")

        # Exit to boolean / Clear Selection
        elif event.type == 'A' and event.value == "PRESS":
            if event.alt:
                self.clear_data()
            else:
                if self.state == State.BOOLEAN:
                    self.state = State.EXTRACT
                else:
                    self.state = State.BOOLEAN
                bpy.ops.hops.display_notification(info=f"Exit : {self.state.name}")

        # Remove last bevel
        elif event.type == 'W' and event.value == "PRESS":
            self.clear_data()
            self.use_last_bevel = not self.use_last_bevel
            bpy.ops.hops.display_notification(info=f"Bevel : {'ON' if self.use_last_bevel else 'OFF'}")
            self.restart_object(context)

        # Decimate
        elif event.type == 'D' and event.value == "PRESS":
            self.clear_data()
            self.use_decimate = not self.use_decimate
            bpy.ops.hops.display_notification(info=f"Decimate : {'ON' if self.use_decimate else 'OFF'}")
            self.restart_object(context)

        # Local View
        elif event.type in {'NUMPAD_SLASH', 'SLASH', 'BACK_SLASH'} and event.value == "PRESS":
            self.use_local_view = not self.use_local_view
            bpy.ops.view3d.localview(frame_selected=False)

        return None


    def select_face(self, context, event, ignore_mouse_satus=False, ignore_draw_data_rebuild=False):

        if not self.mouse_down and not ignore_mouse_satus: return

        mouse = Vector((event.mouse_region_x, event.mouse_region_y))
        origin = view3d_utils.region_2d_to_origin_3d(context.region, context.space_data.region_3d, mouse)
        direction = view3d_utils.region_2d_to_vector_3d(context.region, context.space_data.region_3d, mouse)
        result, location, normal, index, object, matrix = ray_cast_objects(context, origin, direction, [self.obj], evaluated=False)

        if not result: return
        if object != self.obj: return
        if index > len(self.obj.data.polygons) - 1: return
        if index < 0: return

        # This allows click drag
        if index in self.indexes_during_this_mouse_down: return
        self.indexes_during_this_mouse_down.append(index)

        if index not in self.face_indexes:
            self.face_indexes.append(index)
        else:
            self.face_indexes.remove(index)

        if not ignore_draw_data_rebuild:
            self.update_draw_data()


    def select_shortest_path(self, context, event):
        if not self.obj: return
        if not self.mesh: return
        if not self.face_indexes: return

        last_face_selected = self.face_indexes[-1]

        self.select_face(context, event, ignore_mouse_satus=True, ignore_draw_data_rebuild=True)
        new_face_selected = self.face_indexes[-1]

        if last_face_selected == new_face_selected: return

        bpy.ops.object.mode_set(mode='EDIT', toggle=False)

        bpy.ops.mesh.select_all(action='DESELECT')
        bpy.ops.mesh.select_mode(use_extend=False, type="FACE")

        bm = bmesh.from_edit_mesh(self.mesh)
        bm.faces.ensure_lookup_table()

        bm.faces[last_face_selected].select_set(True)
        bm.faces[new_face_selected].select_set(True)
        bm.faces.active = bm.faces[last_face_selected]

        bpy.ops.mesh.shortest_path_select(edge_mode='SELECT')
        bpy.ops.object.mode_set(mode='OBJECT', toggle=False)

        for polygon in self.mesh.polygons:
            if polygon.select:
                if polygon.index not in self.face_indexes:
                    self.face_indexes.append(polygon.index)

        if new_face_selected in self.face_indexes:
            self.face_indexes.remove(new_face_selected)
            self.face_indexes.append(new_face_selected)

        self.update_draw_data()


    def interface(self, context):

        self.master.setup()
        if not self.master.should_build_fast_ui(): return

        # Main
        win_list = []

        msg = "Mode : Extract"
        if self.state == State.BOOLEAN: msg = "Mode : Boolean"
        elif self.state == State.SOLIDIFY: msg = "Mode : Solidify"
        win_list.append(msg)
        win_list.append(f"Decimate : {'ON' if self.use_decimate else 'OFF'}")

        # 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"] = [
            ("/",           f"Local Mode : {'OFF' if context.space_data.local_view else 'ON'}"),
            ("Alt Scroll",  "Cycle Modes"),
            ("D",           "Remove Decimate" if self.use_decimate else "Add Decimate"),
            ("W",           "Remove last Bevel"),
            ("A",          f"Exit to Boolean : {'True' if self.state == State.BOOLEAN else 'False'}"),
            ("S",          f"Exit to Solidify : {'True' if self.state == State.SOLIDIFY else 'False'}"),
            ("Alt A",       "Clear Selection"),
            ("Spacebar",    "Accept"),
            ("Ctrl Click",  "Select Linked"),
            ("Click",       "Select face")]

        if self.show_form:
            help_items["STANDARD"].append(("Tab", f"Dot UI : {'CLOSE' if self.form.is_dot_open() else 'OPEN'}"))

        self.master.receive_fast_ui(win_list=win_list, help_list=help_items, image="FacePanel")
        self.master.finished()

    # --- UTILS --- #

    def setup_object(self, context):

        if context.mode != 'OBJECT':
            bpy.ops.object.mode_set(mode='OBJECT')

        if self.use_local_view:
            if context.space_data.local_view:
                bpy.ops.view3d.localview(frame_selected=False)

        active_obj = context.active_object

        self.obj = active_obj.copy()
        self.obj.data = active_obj.data.copy()

        context.collection.objects.link(self.obj)
        context.view_layer.objects.active = self.obj

        self.obj.show_wire = True

        if self.use_decimate:
            mod = self.obj.modifiers.new("Decimate", 'DECIMATE')
            mod.decimate_type = 'DISSOLVE'
            mod.angle_limit = math.radians(.05)

        if self.use_last_bevel == False:
            for mod in reversed(self.obj.modifiers):
                if mod.type == 'BEVEL':
                    if mod.show_viewport:
                        mod.show_viewport = False
                        break

        bpy.ops.object.select_all(action='DESELECT')
        self.obj.select_set(True)
        bpy.ops.object.convert(target='MESH')
        self.mesh = self.obj.data

        if self.use_local_view:
            bpy.ops.view3d.localview(frame_selected=False)

        self.og_object.hide_set(True)


    def update_draw_data(self):

        matrix = self.obj.matrix_world

        # --- LINES --- #
        verts = []
        indices = []
        offset = 0
        for face_index in self.face_indexes:
            polygon = self.mesh.polygons[face_index]
            vert_indices = polygon.vertices

            normal = polygon.normal

            for vert_indice in vert_indices:
                vert = self.mesh.vertices[vert_indice]
                pos = matrix @ (normal * .005 + vert.co)
                verts.append(pos)

            for i in range(len(vert_indices)):
                j = offset + i
                if i == len(vert_indices) - 1:
                    indices.append((j, offset))
                else:
                    indices.append((j, j + 1))

            offset += len(vert_indices)

        self.line_batch = batch_for_shader(self.shader, 'LINES', {'pos': verts}, indices=indices)

        # --- FACE --- #
        verts = []
        indices = []
        self.mesh.calc_loop_triangles()

        offset = 0
        for loop in self.mesh.loop_triangles:
            if loop.polygon_index not in self.face_indexes: continue

            normal = loop.normal

            for vert_indice in loop.vertices:
                vert = self.mesh.vertices[vert_indice]
                pos = matrix @ (normal * .005 + vert.co)
                verts.append(pos)

            indices.append((offset, offset+1, offset+2))
            offset += 3

        self.face_batch = batch_for_shader(self.shader, 'TRIS', {'pos': verts}, indices=indices)


    def extract(self, context):
        bm = bmesh.new()
        bm.from_mesh(self.mesh)

        # Remove all the other faces
        bm.faces.ensure_lookup_table()
        faces = [ f for f in bm.faces if f.index not in self.face_indexes ]
        bmesh.ops.delete(bm, geom=faces, context='FACES')

        # Remove any loose verts
        bm.faces.ensure_lookup_table()
        bm.verts.ensure_lookup_table()
        face_verts = []
        for face in bm.faces:
            for vert in face.verts:
                face_verts.append(vert)
        verts = []
        for vert in bm.verts:
            if vert not in face_verts:
                verts.append(vert)
        if verts:
            bmesh.ops.delete(bm, geom=verts, context='VERTS')

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

        bm.to_mesh(self.mesh)
        bm.free()

        self.obj.parent = None

        loc, rot, _ = self.obj.matrix_world.decompose()
        sca = Vector((1,1,1))

        mat_loc = Matrix.Translation(loc).to_4x4()
        mat_rot = rot.to_matrix().to_4x4()
        mat_sca = Matrix.Identity(4)

        mat = mat_loc @ mat_rot @ mat_sca
        self.obj.matrix_world = mat


    def restart_object(self, context, cancel_use_last_bevel=False):

        if cancel_use_last_bevel: self.use_last_bevel = True

        self.clear_data()

        if context.space_data.local_view:
            bpy.ops.view3d.localview(frame_selected=False)

        bpy.ops.object.select_all(action='DESELECT')

        context.view_layer.objects.active = self.og_object
        self.og_object.select_set(True)
        self.og_object.hide_set(False)

        bpy.data.objects.remove(self.obj)
        self.obj = None
        bpy.data.meshes.remove(self.mesh)
        self.mesh = None

        self.setup_object(context)


    def popover(self, context):
        if self.__class__.selected_operation != "":
            if self.__class__.selected_operation == "EXTRACT":
                self.state = State.EXTRACT
            elif self.__class__.selected_operation == "BOOLEAN":
                self.state = State.BOOLEAN
            elif self.__class__.selected_operation == "SOLIDIFY":
                self.state = State.SOLIDIFY
            elif self.__class__.selected_operation == "CONFIRM":
                self.confirm_exit(context)
                return True

            bpy.ops.hops.display_notification(info=f'State Set To : {self.__class__.selected_operation}')
            self.__class__.selected_operation = ""

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

        return False


    def clear_data(self):
        self.face_indexes = []
        self.indexes_during_this_mouse_down = []
        self.line_batch = None
        self.face_batch = None

    # --- FORM FUNCS --- #

    def setup_form(self, context, event):
        # MAX WIDTH = 165
        self.form = form.Form(context, event, dot_open=False)

        # Mod Scroll Box
        group = form.Scroll_Group()
        for index, mod in enumerate(self.og_object.modifiers):
            row = group.row()
            # Count
            row.add_element(form.Label(text=str(index + 1), width=25, height=20))
            # Mod name
            text = form.shortened_text(mod.name, width=95, font_size=12)
            row.add_element(form.Label(text=text, width=100, height=20))
            # Visible
            row.add_element(form.Button(
                scroll_enabled=False, text="X", highlight_text="O", tips=["Toggle visibility"],
                width=20, height=20, use_padding=False,
                callback=self.rebuild_from_form, pos_args=(mod, context),
                highlight_hook_obj=mod, highlight_hook_attr='show_viewport'))
            group.row_insert(row)
        row = self.form.row()

        box_height = 160

        if len(self.og_object.modifiers) < 8:
            box_height = 20 * len(self.og_object.modifiers)

        mod_box = form.Scroll_Box(width=165, height=box_height, scroll_group=group, view_scroll_enabled=True)
        row.add_element(mod_box)
        self.form.row_insert(row, active=True)

        self.form.build()


    def rebuild_from_form(self, mod, context):
        mod.show_viewport = not mod.show_viewport
        self.restart_object(context, cancel_use_last_bevel=True)

    # --- EXIT --- #

    def common_exit(self, context):
        self.__class__.operator = None
        self.__class__.selected_operation = ""

        for mod in self.og_object.modifiers:
            if mod.name not in self.og_mod_visibility: continue
            mod.show_viewport = self.og_mod_visibility[mod.name]

        self.og_object.hide_set(False)
        if context.space_data.local_view:
            bpy.ops.view3d.localview(frame_selected=False)
        self.remove_shaders()
        collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
        self.master.destroy_immediate()


    def confirm_exit(self, context):

        # Nothing selected
        if not self.face_indexes:
            return self.cancel_exit(context)

        self.common_exit(context)
        self.extract(context)

        context.view_layer.objects.active = self.obj
        bpy.ops.mesh.customdata_custom_splitnormals_clear()

        if self.state == State.SOLIDIFY:
            bpy.ops.hops.adjust_tthick('INVOKE_DEFAULT')
            self.obj.show_wire = False

        elif self.state == State.BOOLEAN:
            bpy.ops.object.mode_set(mode='EDIT')
            bpy.ops.hops.sel_to_bool_v3('INVOKE_DEFAULT', override_obj_name=self.og_object.name)

        elif self.state == State.EXTRACT:
            bpy.ops.hops.draw_wire_mesh_launcher('INVOKE_DEFAULT', object_name=self.obj.name)
            self.obj.show_wire = False

        return {'FINISHED'}


    def cancel_exit(self, context):
        self.common_exit(context)

        bpy.data.objects.remove(self.obj)
        bpy.data.meshes.remove(self.mesh)
        self.og_object.select_set(True)
        context.view_layer.objects.active = self.og_object

        return {'CANCELLED'}

    # --- 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")
        if self.draw_handle_3D:
            self.draw_handle_3D = bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle_3D, "WINDOW")


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


    def draw_shader_2D(self, context):
        if self.show_form:
            self.form.draw()


    def safe_draw_3D(self, context):
        method_handler(self.draw_shader_3D,
            arguments = (context,),
            identifier = 'Modal Shader 3D',
            exit_method = self.remove_shaders)


    def draw_shader_3D(self, context):

        if self.line_batch == None:
            if self.face_batch == None:
                return

        #Enable(GL_LINE_SMOOTH)
        gpu.state.blend_set('ALPHA')
        gpu.state.depth_test_set('LESS')
        #glDepthFunc(GL_LESS)

        self.shader.bind()
        color = [1,0,0,1]
        if self.state == State.EXTRACT:
            color = [0,0,1,1]
        if self.state == State.SOLIDIFY:
            color = [1,0,1,1]
        self.shader.uniform_float('color', color)

        if self.line_batch:
            gpu.state.line_width_set(2)
            self.line_batch.draw(self.shader)

        if self.face_batch:
            color[3] = .1
            self.shader.uniform_float('color', color)
            self.face_batch.draw(self.shader)

        #Disable(GL_LINE_SMOOTH)
        gpu.state.depth_test_set('NONE')
        gpu.state.face_culling_set('NONE')
        gpu.state.depth_test_set('NONE')

# --- POPOVER --- #

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

    op = HOPS_OT_FaceExtract.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='Extract')
    props.calling_ops = 'FACE_EXTRACT'
    props.str_1 = 'EXTRACT'

    row = layout.row()
    row.scale_y = 2
    props = row.operator(broadcaster, text='Boolean')
    props.calling_ops = 'FACE_EXTRACT'
    props.str_1 = 'BOOLEAN'

    row = layout.row()
    row.scale_y = 2
    props = row.operator(broadcaster, text='Solidify')
    props.calling_ops = 'FACE_EXTRACT'
    props.str_1 = 'SOLIDIFY'

    if op.face_indexes:
        row = layout.row()
        row.scale_y = 2
        props = row.operator(broadcaster, text='Confirm')
        props.calling_ops = 'FACE_EXTRACT'
        props.str_1 = 'CONFIRM'
    else:
        row = layout.row()
        row.scale_y = 2
        row.label(text="Select faces before continuing.")