OverviewFilesCommitsIssuesDocsReleasesPeers

hardops / operators / booleans / bool_shift.py

804 lines · python ref:
import bpy
from ... utils.objects import set_active
from ... material import assign_material
from ... utility import addon
from ... utility import collections, modifier, geometry_nodes
from ... utility.renderer import cycles
from ... ui_framework.master import Master
from ... ui_framework.operator_ui import Master as InstantMaster
from ... utils.toggle_view3d_panels import collapse_3D_view_panels
from ...utility.base_modal_controls import Base_Modal_Controls


DESC = """Bool Shift
LMB - Change an existing boolean relationship
Shift + LMB - Perform the shift operation you probably want and finish instantly
Ctrl + LMB - Keep bevel modifiers on inset objects if they don't use vertex groups or bevel weight"""


def selector_update(self, context):
    operator = HOPS_OT_BoolShift.operator
    if not operator: return

    operator.report_info(operator.operation)
    operator.cancel(context)
    operator.execute(context)

operation_items = [
    ('INTERSECT', "Intersect", "Peform an intersect operation"),
    ('UNION', "Union", "Peform a union operation"),
    ('DIFFERENCE', "Difference", "Peform a difference operation"),
    ('SLASH', "Slash", "Peform a slash operation"),
    ('INSET', "Inset", "Peform an inset operation"),
    ('OUTSET', "Outset", "Peform an outset operation"),
    ('CLEAR', "Clear", "Clear the boolean relationship")]

operation_items_nodes = [
    ('INTERSECT', "Intersect", "Peform an intersect operation"),
    ('UNION', "Union", "Peform a union operation"),
    ('DIFFERENCE', "Difference", "Peform a difference operation"),
    ('SLASH', "Slash", "Peform a slash operation"),
    ('INSET', "Inset", "Peform an inset operation"),
    ('KNIFE', "Knife", "Peform a kinfe operation"),
    ('CLEAR', "Clear", "Clear the boolean relationship")]

def operation_items_get(self, context):
    return operation_items_nodes if WORKFLOW_NODES else operation_items

OPERATIONS_INPUT = None
WORKFLOW_NODES = False


class HOPS_OT_BoolShift(bpy.types.Operator):
    bl_idname = "hops.bool_shift"
    bl_label = "Hops Shift Boolean"
    bl_options = {'REGISTER', 'UNDO', 'BLOCKING'}
    bl_description = DESC

    operator = None

    operation: bpy.props.EnumProperty(
        name="Operation",
        description="What kind of boolean operation to change to",
        items=operation_items_get,
        update=selector_update)

    thickness: bpy.props.FloatProperty(
        name="Thickness",
        description="How deep the inset should cut",
        default=0.10,
        min=0.00,
        precision=3)

    thickness_nodes: bpy.props.FloatProperty(
        name="Thickness",
        description="How deep the inset should cut",
        default=0.10,
        precision=3)

    keep_bevels: bpy.props.BoolProperty(
        name="Keep Bevels",
        description="Keep Bevel modifiers on inset objects enabled if they don't use vertex groups or bevel weight",
        default=False)

    inset_slice: bpy.props.BoolProperty(
        name="Inset Slice",
        description="Create Slice from inset volume",
        default=False)

    transfer_normals: bpy.props.BoolProperty(
        name="Transfer Normals",
        description="Write current normals of inputs as Custom Normals",
        default=False)

    weld: bpy.props.BoolProperty(
        name="Weld",
        description="Weld duplicate vertices after boolean",
        default=False)

    merge_threshold: bpy.props.FloatProperty(
        name="Merge Threshold",
        description="Distance below whcih vertices are merged",
        default=0.0001,
        min=0.00,
        precision=4)

    stitch_distance: bpy.props.FloatProperty(
        name="Stitch Distance",
        description="Distance below whcih vertices of new edges are stitched",
        default=0.0001,
        min=0.00,
        precision=4)

    non_manifold: bpy.props.BoolProperty(
        name="Non-manifold",
        description="Makes cutter double-sided enabling use of non-manifold geometry.",
        default=False)

    @classmethod
    def poll(cls, context):
        return context.mode == 'OBJECT'


    def draw(self, context):
        # Always draw the boolean operation
        self.layout.prop(self, "operation")

        nodes = addon.preference().property.workflow_modifier == 'NODES'

        # Thickness and Keep Bevels are only relevant to inset and outset
        if self.operation in ('INSET', 'OUTSET'):
            self.layout.prop(self, "thickness_nodes" if nodes else "thickness")
            self.layout.prop(self, "keep_bevels")

        if self.operation == 'INSET':
            self.layout.prop(self, "inset_slice")

        if nodes:
            if self.operation == 'KNIFE':
                row = self.layout.row()
                row.prop(self, "non_manifold")

                row.prop(self, "stitch_distance")
                self.layout.separator()

            row = self.layout.row()
            row.prop(self, "transfer_normals")
            row = self.layout.row()
            row.prop(self, "weld")
            row = row.split()
            row.enabled = self.weld
            row.prop(self, "merge_threshold")


    def invoke(self, context, event):

        self.inset_slice = False

        # Shift Click performs Instant Shift
        if event.type == 'LEFTMOUSE' and event.shift:
            return self.instant_slash(context, event)

        self.thick_adjust = False
        # It is necessary to push an undo step here to prevent issues
        bpy.ops.ed.undo_push()

        # Keep this consistent with bool_inset
        self.keep_bevels = event.ctrl

        global WORKFLOW_NODES
        WORKFLOW_NODES = addon.preference().property.workflow_modifier == 'NODES'

        # Find the likely desired operation
        booleans = self.find_booleans(context)
        cutters = self.find_cutters(context)
        slashes = self.find_slashes(booleans, cutters)
        self.operation = self.initial_operation(booleans, cutters, slashes)

        global OPERATIONS_INPUT
        OPERATIONS_INPUT = [itm[0] for itm in operation_items_get(None, None)]

        # Let the user know what we're doing, then do it
        self.report_info(self.operation)
        self.execute(context)

        # UI System
        self.master = Master(context=context)
        self.master.only_use_fast_ui = True
        #self.timer = context.window_manager.event_timer_add(0.025, window=context.window)

        self.original_tool_shelf, self.original_n_panel = collapse_3D_view_panels()
        context.window_manager.modal_handler_add(self)
        self.base_controls = Base_Modal_Controls(context, event, popover_keys=['TAB', 'SPACE'])

        self.__class__.operator = self

        return {'RUNNING_MODAL'}


    def modal(self, context, event):

        self.master.receive_event(event=event)
        self.base_controls.update(context, event)

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

        # Pass through Shift + Z and Alt + Z for xray shortcuts
        elif event.type == 'Z' and (event.shift or event.alt):
            return {'PASS_THROUGH'}

        elif self.base_controls.popover:
            context.window_manager.popover(popup_draw)

        # Left Click or Space confirm
        elif self.base_controls.confirm:
            self.report_info('FINISHED')
            collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
            self.master.run_fade()
            self.__class__.operator = None
            return {'FINISHED'}

        # Right Click or Esc cancel
        elif self.base_controls.cancel:
            self.report_info('CANCELLED')
            self.cancel(context)
            collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
            self.master.run_fade()
            self.__class__.operator = None
            return {'CANCELLED'}

        # You can use mouse scroll or X and Z to scroll through boolean operations
        elif self.base_controls.scroll or (event.type in {'X', 'Z'} and event.value == 'PRESS'):
            if event.type == 'X':
                self.scroll(1)
            elif event.type == 'Z':
                self.scroll(-1)
            else:
                self.scroll(self.base_controls.scroll)

            # Let the user know what we're doing, then do it
            self.report_info(self.operation)
            self.cancel(context)
            self.execute(context)

        elif event.type == 'T' and event.value == 'PRESS' :
            self.thick_adjust = not self.thick_adjust

        elif self.base_controls.mouse and self.thick_adjust and self.operation in {'INSET', 'OUTSET'}:
            self.thickness +=self.base_controls.mouse
            self.thickness_nodes +=self.base_controls.mouse
            self.cancel(context)
            self.execute(context)

        # H opens the help
        if event.type == 'H' and event.value == 'PRESS':
            addon.preference().property.hops_modal_help = not addon.preference().property.hops_modal_help
            context.area.tag_redraw()

        self.draw_modal(context)
        context.area.tag_redraw()
        return {'RUNNING_MODAL'}


    def execute(self, context):

        # Get the booleans, cutters, and slashes
        booleans = self.find_booleans(context)
        cutters = self.find_cutters(context)
        slashes = self.find_slashes(booleans, cutters)

        # List of objects we've removed
        removed = []

        # Iterate through boolean modifiers using chosen cutters
        for cutter in cutters:

            # These are the targets of this cutter
            for target_one, modifier_one in booleans[cutter]:

                # If this target has been removed, skip it
                if target_one in removed:
                    continue

                # Only do these operations if the target is not an inset object
                if not (target_one.hops.status == 'BOOLSHAPE' and modifier_one == target_one.modifiers[-1]):

                    # Handle difference to slash
                    if self.operation == 'SLASH':

                        # Make sure we're not making duplicate slashes
                        if not slashes[cutter]:
                            slash = self.slash(context, target_one, modifier_one, cutter)
                            slashes[cutter].append(slash)

                    # Handle slash to other
                    elif target_one in slashes[cutter]:

                        # Delete the existing slash object
                        bpy.data.objects.remove(target_one, do_unlink=True)

                        # And add it to the removed list
                        removed.append(target_one)

                        # Skip the rest of this loop because the target no longer exists
                        continue

                    # Handle difference to inset
                    if self.operation in ('INSET', 'OUTSET'):
                        self.inset(context, target_one, modifier_one, cutter, None)

                    # Handle difference to difference
                    if self.operation in ('DIFFERENCE', 'UNION', 'INTERSECT', 'KNIFE'):
                        self.difference(context, target_one, modifier_one, cutter)

                    # Handle difference to none
                    if self.operation == 'CLEAR':
                        self.clear(context, target_one, modifier_one, cutter)

                # Only do this for loop if we're sure that the target is an inset object
                else:

                    # These are the targets of this inset
                    for target_two, modifier_two in booleans[target_one]:

                        # Only do these operations if the target is not an inset object
                        if not (target_two.hops.status == 'BOOLSHAPE' and modifier_two == target_two.modifiers[-1]):

                            # Handle inset to slash
                            if self.operation == 'SLASH':

                                # Make sure we're not making duplicate slashes
                                if not slashes[cutter]:
                                    slash = self.slash(context, target_two, modifier_two, cutter)
                                    slashes[cutter].append(slash)

                            # Make sure the inset object still exists
                            if target_one not in removed:

                                # Handle inset to inset
                                if self.operation in ('INSET', 'OUTSET'):
                                    self.inset(context, target_two, modifier_two, cutter, target_one)

                                # Handle inset to other
                                else:

                                    # Delete the existing inset object
                                    bpy.data.objects.remove(target_one, do_unlink=True)

                                    # And add it to the removed list
                                    removed.append(target_one)

                            # Handle inset to difference
                            if self.operation in ('DIFFERENCE', 'UNION', 'INTERSECT'):
                                self.difference(context, target_two, modifier_two, cutter)

                            # Handle difference to none
                            if self.operation == 'CLEAR':
                                self.clear(context, target_two, modifier_two, cutter)

        # Success!
        return {'FINISHED'}


    def cancel(self, context):
        bpy.ops.ed.undo_push()
        bpy.ops.ed.undo()


    def instant_slash(self, context, event):

        # Keep this consistent with bool_inset
        self.keep_bevels = event.ctrl

        # Find the likely desired operation
        booleans = self.find_booleans(context)
        cutters = self.find_cutters(context)
        slashes = self.find_slashes(booleans, cutters)
        self.operation = self.initial_operation(booleans, cutters, slashes)

        # Let the user know what we're doing, then do it
        self.report_info(self.operation)
        self.execute(context)

        instant_master = InstantMaster()

        draw_data = [
            ["Instant Shift"],
            ["Operation", self.operation.capitalize()],
        ]

        instant_master.receive_draw_data(draw_data=draw_data)
        instant_master.draw()

        return {'FINISHED'}


    def report_info(self, info):
        words = [w.capitalize() for w in str(info).split("_")]
        self.report({'INFO'}, " ".join(words))


    def scroll(self, direction):
        operations = OPERATIONS_INPUT
        index = (operations.index(self.operation) + direction) % len(operations)
        self.operation = operations[index]


    def find_booleans(self, context):

        # Lookup table to find booleans using a cutter
        booleans = {o:[] for o in context.blend_data.objects}
        for obj in context.blend_data.objects:

            # Only add objects that are linked to a collection
            if obj.users_collection:
                for mod in obj.modifiers:

                    # Only add boolean modifiers that have a cutter
                    bool_object = modifier.bool_object_get(mod)
                    if bool_object:

                        # Only add insets and visible targets
                        if obj.hops.status == 'BOOLSHAPE' or obj.visible_get():
                            booleans[bool_object].append((obj, mod))

        return booleans


    def find_cutters(self, context):

        # List of cutters to iterate through
        cutters = []
        for obj in context.selected_objects:

            # If this object is not a boolshape, skip it
            if obj.hops.status != 'BOOLSHAPE':
                continue

            # Get the last modifier on this cutter
            mod = obj.modifiers[-1] if obj.modifiers else None

            inset_cutter = None
            # If this cutter is an inset, add its intersect cutter to the list instead
            if mod:
                if mod.type == 'BOOLEAN' and mod.operation == 'INTERSECT':
                    inset_cutter = mod.object

                # treat inset slices as intersect booleans
                elif mod.type == 'NODES':
                    wrapper = geometry_nodes.NodeBoolean.new(mod)

                    if wrapper and wrapper.operation == 'INSET' and wrapper.inset_slice:
                        inset_cutter = wrapper.object

            if inset_cutter and inset_cutter.users_collection:
                    cutters.append(inset_cutter)

            # Otherwise it's not an inset, so just add it to the list
            elif obj.users_collection:
                cutters.append(obj)

        # Filter out any duplicate items just in case
        cutters = list(dict.fromkeys(cutters))

        return cutters


    def find_slashes(self, booleans, cutters):

        # Lookup tables for finding difference and intersect targets of a cutter
        dif_bools = {o:[] for o in cutters}
        int_bools = {o:[] for o in cutters}
        for cutter in cutters:
            for target, modifier in booleans[cutter]:

                wrapper = geometry_nodes.NodeBoolean.new(modifier)
                if wrapper:
                    modifier = wrapper

                # We care about slashes, not insets
                if target.hops.status != 'BOOLSHAPE':
                    if modifier.operation == 'DIFFERENCE':
                        dif_bools[cutter].append(target)
                    elif modifier.operation == 'INTERSECT':
                        int_bools[cutter].append(target)

        # Lookup table for finding slashes of a cutter
        slashes = {o:[] for o in cutters}
        for cutter in cutters:

            # If this cutter has any difference targets
            if dif_bools[cutter]:

                # Then assume any intersect targets are slashes
                for target in int_bools[cutter]:
                    slashes[cutter].append(target)

        return slashes


    def initial_operation(self, booleans, cutters, slashes):
        global WORKFLOW_NODES

        # Base the initial operation on the first boolean we find
        for cutter in cutters:
            for target_one, modifier_one in booleans[cutter]:

                wrapper = geometry_nodes.NodeBoolean.new(modifier_one)
                if wrapper:
                    modifier_one = wrapper

                else:
                    WORKFLOW_NODES = False

                # We're not dealing with inset
                if not (target_one.hops.status == 'BOOLSHAPE' and modifier_one == target_one.modifiers[-1]):

                    # If we're dealing with slash, shift to difference
                    if slashes[cutter]:
                        return 'DIFFERENCE'

                    # If we're dealing with difference, shift to slash
                    elif modifier_one.operation == 'DIFFERENCE':
                        return 'SLASH'

                    # If we're dealing with union or intersect, shift to difference
                    elif modifier_one.operation in ('UNION', 'INTERSECT'):
                        return 'DIFFERENCE'

                    else:
                        return 'DIFFERENCE'

                # We're dealing with inset
                for target_two, modifier_two in booleans[target_one]:
                    wrapper = geometry_nodes.NodeBoolean.new(modifier_two)
                    if wrapper:
                        modifier_two = wrapper

                    else:
                        WORKFLOW_NODES = False

                    # There is only inset for nodes, so kinfe?
                    if wrapper and WORKFLOW_NODES:
                        if wrapper.operation == 'INSET':
                            return 'KNIFE'

                    # If we're dealing with outset, shift to inset
                    elif modifier_two.operation == 'DIFFERENCE':
                        return 'OUTSET' if not WORKFLOW_NODES else 'INSET'

                    # If we're dealing with inset, shift to outset
                    elif modifier_two.operation == 'UNION':
                        return 'INSET'

        # If for some reason we can't find anything, shift to clear
        return 'CLEAR'

    def nodes_pref(self, wrapper):
        wrapper.transfer_normals = self.transfer_normals
        wrapper.weld = self.weld
        wrapper.merge_threshold = self.merge_threshold
        wrapper.stitch_distance = self.stitch_distance

        if wrapper.operation == 'KNIFE' and self.non_manifold:
            wrapper.solver = 'EXACT'
            wrapper.non_manifold = True

    def difference(self, context, target, modifier, cutter):

        wrapper = geometry_nodes.NodeBoolean.new(modifier)
        if wrapper:
            wrapper.operation = self.operation
            wrapper.cutter = cutter
            self.nodes_pref(wrapper)
            return

        # Set the boolean modifier to the desired operation
        modifier.operation = self.operation

        # For inset to difference it's important we make sure to set the object
        modifier.object = cutter

    def slash(self, context, target, modifier_, cutter):

        # The newly created object
        slash = None

        # There's no point creating slash objects of boolshapes
        if target.hops.status != 'BOOLSHAPE':

            # First create the slash object
            slash = target.copy()
            slash.data = target.data.copy()
            target.users_collection[0].objects.link(slash)
            _object = modifier.bool_object_get(modifier_)

            # Then iterate through its modifiers
            start_deleting = False
            for mod in slash.modifiers:

                _mod = modifier.bool_mod_get(mod)

                # Check whether this modifier is the one we're replacing on the target
                if _mod and _mod.object == _object:

                    # And set it to intersect
                    _mod.operation = 'INTERSECT'

                    # For inset to slash it's important we make sure to set the object
                    _mod.object = cutter

                    # Then start deleting modifiers
                    start_deleting = True

                    if mod.type == 'NODES':
                        self.nodes_pref(_mod)

                # Which come after this one
                elif modifier.mod_type(mod) == 'BOOLEAN' and start_deleting:
                    slash.modifiers.remove(mod)

        wrapper = geometry_nodes.NodeBoolean.new(modifier_)

        if wrapper:
            modifier_ = wrapper
            self.nodes_pref(modifier_)

        # Set the original modifier to difference
        modifier_.operation = 'DIFFERENCE'

        # For inset to slash it's important we make sure to set the object
        modifier_.object = cutter

        # Return the new object
        return slash


    def inset(self, context, target, modifier, cutter, inset):

        mod = geometry_nodes.NodeBoolean.new(modifier)
        if mod:
            mod.operation = 'INSET'
            mod.inset_thickness = self.thickness_nodes
            self.nodes_pref(mod)

            if self.operation == 'INSET' and self.inset_slice:
                slice_inset = target.copy()
                slice_inset.data = target.data.copy()

                for col in target.users_collection:
                        col.objects.link(slice_inset)

                mod = geometry_nodes.NodeBoolean.new_unchecked(slice_inset.modifiers[modifier.name])
                mod.inset_slice = True
                self.nodes_pref(mod)

            return

        # Modify the existing inset if there is one
        if inset:

            # Iterate through the modifiers backwards to get the last solidify
            for mod in [m for m in inset.modifiers][::-1]:
                if mod.type == 'SOLIDIFY':
                    mod.thickness = self.thickness
                    break

        # Otherwise create a new inset object
        else:
            inset = target.copy()
            inset.data = target.data.copy()
            collections.link_obj(context, inset, "Cutters")
            cycles.hide_preview(context, inset)
            inset.hops.status = 'BOOLSHAPE'
            inset.display_type = 'WIRE'
            inset.hide_render = True

            # Iterate through its modifiers
            start_deleting = False
            for mod in inset.modifiers:

                # Check whether this modifier is the one we're replacing on the target
                if mod.type == 'BOOLEAN' and mod.object == modifier.object:

                    # Then start deleting modifiers
                    start_deleting = True

                # Including this one and all that come after it
                if start_deleting:
                    inset.modifiers.remove(mod)

            # First create a solidify modifier on the inset
            solidify = inset.modifiers.new(name="Solidify", type='SOLIDIFY')
            solidify.use_even_offset = True
            solidify.thickness = self.thickness
            solidify.offset = 0.0
            solidify.show_expanded = True

            # Then create a boolean intersect modifier on the inset
            intersect = inset.modifiers.new(name="Boolean", type='BOOLEAN')
            intersect.operation = 'INTERSECT'
            intersect.object = cutter

            if hasattr(intersect, 'solver'):
                intersect.solver = addon.preference().property.boolean_solver

        # Handle existing bevel modifiers
        for mod in inset.modifiers:
            if mod.type == 'BEVEL' and not self.keep_bevels:
                if mod.limit_method not in ('VGROUP', 'WEIGHT'):
                    mod.show_render = mod.show_viewport = False
            elif mod.type == 'WEIGHTED_NORMAL':
                inset.modifiers.remove(mod)

        # Set the original modifier to the desired operation
        modifier.operation = 'DIFFERENCE' if self.operation == 'INSET' else 'UNION'

        # And change the original modifier's object to the newly created inset
        modifier.object = inset

        if self.operation == 'INSET' and self.inset_slice:
            slice_inset = target.copy()
            slice_inset.data = target.data.copy()

            for col in target.users_collection:
                    col.objects.link(slice_inset)

            mod = slice_inset.modifiers[modifier.name]
            mod.operation = 'INTERSECT'
            mod.object = inset


    def clear(self, context, target, modifier, cutter):

        # Simply remove the boolean modifier
        target.modifiers.remove(modifier)
        cutter.hops.status = "UNDEFINED"
        cutter.display_type = 'SOLID'


    def draw_modal(self, context):

        operation = str(self.operation).capitalize()
        self.master.setup()
        if not self.master.should_build_fast_ui(): return

        # Main
        win_list = []
        if addon.preference().ui.Hops_modal_fast_ui_loc_options != 1:
            win_list.append(operation)
            if self.operation in ('INSET', 'OUTSET'):
                win_list.append(f"{self.thickness:.2f}")
        else:
            win_list.append("Shift Boolean")
            win_list.append(f"Mode : {operation}")
            if self.operation in ('INSET', 'OUTSET'):
                win_list.append(f"Thickness : {self.thickness:.3f}")
                if context.active_object.mode == 'OBJECT':
                    win_list.append(f"Keep Bevels : {self.keep_bevels}")

        # 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"] = [
            ("Wheel",  "Switch Operation"),
            ("X / Z",  "Switch Operation")
        ]

        if 'SPACE' in self.base_controls.popover_keys:
            help_items["STANDARD"].append(('Space', 'Open Select Menu'))
        elif 'TAB' in self.base_controls.popover_keys:
            help_items["STANDARD"].append(('TAB', 'Open Select Menu'))


        if self.operation in ('INSET', 'OUTSET'):
            help_items["STANDARD"].append(["T",  "Thickness Adjust"])

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


# --- Popover --- #

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

    data = HOPS_OT_BoolShift.operator
    if not data: return {'CANCELLED'}

    layout.label(text= 'Selector')
    vals = (item[0] for item in operation_items_get(None, None))

    for val in vals:
        row = layout.row()
        row.scale_y = 2
        row.prop_enum(data, 'operation', val)