OverviewFilesCommitsIssuesDocsReleasesPeers

hardops / operators / booleans / bool_stack.py

452 lines · python ref:
from typing import List

import bpy
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, StringProperty
from bpy.types import BooleanModifier, Context, Event, Modifier, Object, Operator, PropertyGroup, UILayout

from ... utility import addon, version
from ...ui_framework.operator_ui import Master as UIMaster
from ...utility.modifier import new as modifier_new
from ...utility.modifier import stored as modifier_stored

# You can't use this operator without geometry nodes.
try:
    from bpy.types import GeometryNodeObjectInfo, NodesModifier
    try:
        from bpy.types import GeometryNodeMeshBoolean
    except ImportError:
        from bpy.types import GeometryNodeBoolean as GeometryNodeMeshBoolean
except ImportError:
    GeometryNodeMeshBoolean, GeometryNodeObjectInfo, NodesModifier = [type(None)] * 3
    SUPPORT_GEOMETRY_NODES = False
else:
    SUPPORT_GEOMETRY_NODES = True

# Track if node support solver selection
NODE_SOLVER = SUPPORT_GEOMETRY_NODES and 'solver' in GeometryNodeMeshBoolean.bl_rna.properties

class BoolStackItem(PropertyGroup):
    name: StringProperty(name='Name', description='Name of the first/only modifier')
    select: BoolProperty(name='Select', description='Whether to (un)stack this item')
    booleans: StringProperty(name='Booleans', description='List of booleans to stack')

    def draw(self, layout: UILayout):
        icon = 'RADIOBUT_ON' if self.select else 'RADIOBUT_OFF'
        layout.prop(self, 'select', text=self.name, icon=icon, toggle=True)


class HOPS_OT_BoolStack(Operator):
    bl_idname = 'hops.bool_stack'
    bl_label = 'Hops Stack Booleans'
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = '\n'.join((
        'Geometry Node - Stack / Unstack',
        '',
        'Convert booleans into geometry nodes or geometry nodes into booleans.',
        '',
        'LMB - Stack mode.',
        'Alt - Unstack mode.',
        'Ctrl - Include exact booleans when stacking.' if NODE_SOLVER else '',
        'Shift - Stack all booleans / unstack all node groups',  # Blender adds the last period.
    ))

    mode: EnumProperty(
        name='Mode',
        items=[
            ('STACK', 'Stack', 'Convert a set of consecutive boolean modifiers into a geometry nodes modifier'),
            ('UNSTACK', 'Unstack', 'Convert a geometry nodes modifier back into individual boolean modifiers'),
        ],
        default='STACK',
        options={'SKIP_SAVE'},
    )

    # Options for stack:
    stack_exact_booleans: BoolProperty(
        name='Stack Exact Booleans',
        description='Convert boolean modifiers that use the exact solver (nodes do not support it)',
        default=False,
        options={'SKIP_SAVE'},
    )

    stack_items: CollectionProperty(
        name='Stack Items',
        description='Property groups containing information about sets of booleans',
        type=BoolStackItem,
        options={'SKIP_SAVE'},
    )

    stack_items_exact: CollectionProperty(
        name='Stack Items Exact',
        description='Property groups containing information about sets of booleans (including exact booleans)',
        type=BoolStackItem,
        options={'SKIP_SAVE'},
    )

    # Options for unstack:
    unstack_items: CollectionProperty(
        name='Unstack Items',
        description='Property groups containing information about geometry nodes modifiers',
        type=BoolStackItem,
        options={'SKIP_SAVE'},
    )

    def get_items(self) -> List[BoolStackItem]:
        if self.mode == 'STACK':
            if not self.stack_exact_booleans:
                return self.stack_items
            else:
                return self.stack_items_exact

        elif self.mode == 'UNSTACK':
            return self.unstack_items

    def draw(self, context: Context):
        layout = self.layout.column(align=True)

        row = layout.row(align=True)
        row.prop(self, 'mode', expand=True)

        if self.mode == 'STACK' and not NODE_SOLVER:
            icon = 'CHECKBOX_HLT' if self.stack_exact_booleans else 'CHECKBOX_DEHLT'
            layout.prop(self, 'stack_exact_booleans', icon=icon, toggle=True)

        layout.separator()
        items = self.get_items()

        if items:
            for item in items:
                item.draw(layout)
        else:
            layout.label(text='No eligible modifiers found')

    @classmethod
    def poll(cls, context: Context) -> bool:
        if not SUPPORT_GEOMETRY_NODES:
            return False

        object: Object = context.active_object
        return (object is not None) and (object.type == 'MESH') and (object.mode == 'OBJECT')

    def invoke(self, context: Context, event: Event) -> set:
        object: Object = context.active_object

        # Remove dead booleans so they don't split up boolean sets.
        _remove_dead_boolean_modifiers(object)

        # Set options based on modifier keys.
        self.mode = 'UNSTACK' if event.alt else 'STACK'

        if NODE_SOLVER:
            self.stack_exact_booleans = True
        else:
            self.stack_exact_booleans = event.ctrl

        # Populate stack items.
        for modifier_set in _boolean_modifier_sets(object, False):
            item: BoolStackItem = self.stack_items.add()
            item.name = f'{modifier_set[0].name} - {modifier_set[-1].name}'
            item.select = event.shift
            item.booleans = repr([modifier.name for modifier in modifier_set])

        # Select first stack item.
        if self.stack_items:
            item: BoolStackItem = self.stack_items[0]
            item.select = True

        # Populate stack items (with exact).
        for modifier_set in _boolean_modifier_sets(object, True):
            item: BoolStackItem = self.stack_items_exact.add()
            item.name = f'{modifier_set[0].name} - {modifier_set[-1].name}'
            item.select = event.shift
            item.booleans = repr([modifier.name for modifier in modifier_set])

        # Select first stack item (with exact).
        if self.stack_items_exact:
            item: BoolStackItem = self.stack_items_exact[0]
            item.select = True

        # Populate unstack items.
        for modifier in _nodes_modifiers(object):
            item: BoolStackItem = self.unstack_items.add()
            item.name = modifier.name
            item.select = event.shift

        # Select last unstack item.
        if self.unstack_items:
            item: BoolStackItem = self.unstack_items[-1]
            item.select = True

        # Get the original modifier counts for the notification.
        self.original_boolean_count = len(list(filter(_check_boolean_modifier, object.modifiers)))
        self.original_nodes_count = len(list(filter(_check_nodes_modifier, object.modifiers)))

        # Execute before the notification to get converted modifier counts.
        self.execute(context)
        self.notification(context, event)

        return {'FINISHED'}

    def notification(self, context: Context, event: Event):
        object: Object = context.active_object
        items = self.get_items()

        if self.mode == 'STACK':
            draw_title = ['Boolean Stack']
            if items:
                draw_data = [
                    ['Convert Exact Booleans', self.stack_exact_booleans],
                    ['Convert All Sets', event.shift],
                    ['Node Groups Created', self.converted_nodes_count],
                    ['Booleans Removed', f'{self.converted_boolean_count} / {self.original_boolean_count}'],
                    ['Modifiers Total', len(object.modifiers)],
                ]
                if NODE_SOLVER:
                    draw_data = draw_data[1:]
            elif self.stack_items_exact:  # Using exact would have given eligible modifiers.
                draw_data = [
                    ['No eligible modifiers found'],
                    ['Try including exact booleans'],
                ]
            else:
                draw_data = [['No eligible modifiers found']]

        elif self.mode == 'UNSTACK':
            draw_title = ['Boolean Unstack']
            if items:
                draw_data = [
                    ['Convert All Stacks', event.shift],
                    ['Booleans Created', f'{self.converted_boolean_count}'],
                    ['Node Groups Removed', f'{self.converted_nodes_count} / {self.original_nodes_count}'],
                    ['Modifiers Total', len(object.modifiers)],
                ]
            else:
                draw_data = [['No eligible modifiers found']]

        # Our UI code expects all items except the title to be in reverse order.
        draw_data.append(draw_title)
        draw_data.reverse()

        ui = UIMaster()
        ui.receive_draw_data(draw_data=draw_data)
        ui.draw(draw_bg=addon.preference().ui.Hops_operator_draw_bg, draw_border=addon.preference().ui.Hops_operator_draw_border)

    def execute(self, context: Context) -> set:
        object: Object = context.active_object
        items = self.get_items()

        # Keep track of converted modifier counts for the notification.
        self.converted_boolean_count = 0
        self.converted_nodes_count = 0

        if not items:
            self.report({'INFO'}, 'No eligible modifiers found')

        elif self.mode == 'STACK':
            self.execute_stack(object, items)
            self.report({'INFO'}, f'Consolidated {self.converted_boolean_count} booleans to {self.converted_nodes_count} node groups')

        elif self.mode == 'UNSTACK':
            self.execute_unstack(object, items)
            self.report({'INFO'}, f'Dissolved {self.converted_nodes_count} node groups to {self.converted_boolean_count} booleans')

        return {'FINISHED'}

    def execute_stack(self, object: Object, items: List[BoolStackItem]):
        for item in items:
            if not item.select:
                continue

            # Get the boolean modifiers for this item.
            boolean_modifiers: List[BooleanModifier] = [object.modifiers.get(name) for name in eval(item.booleans)]

            # Backup and remove all modifiers after the last boolean.
            stored_modifiers = _backup_modifiers(object, object.modifiers.find(boolean_modifiers[-1].name) + 1)

            # Include the number of booleans in the modifier name.
            nodes_modifier: NodesModifier = object.modifiers.new(f'{len(boolean_modifiers)} Booleans', 'NODES')
            nodes_modifier.show_expanded = False  # Collapse created modifiers.

            # Name the node group after the modifier and the object.
            node_group = bpy.data.node_groups.new(f'{nodes_modifier.name} ({object.name})', 'GeometryNodeTree')
            nodes_modifier.node_group = node_group

            input_node = node_group.nodes.new("NodeGroupInput")
            input_node.location = (-200, 0)
            output_node = node_group.nodes.new("NodeGroupOutput")
            output_node.location = (len(boolean_modifiers) * 200, 0)

            # Group I/O
            if bpy.app.version < (4, 0):
                node_group.inputs.clear()
                node_group.inputs.new("NodeSocketGeometry", "Geometry")
                node_group.outputs.clear()
                node_group.outputs.new("NodeSocketGeometry", "Geometry")

            else:
                node_group.interface.clear()
                node_group.interface.new_socket(socket_type="NodeSocketGeometry", name="Geometry", in_out='INPUT')
                node_group.interface.new_socket(socket_type="NodeSocketGeometry", name="Geometry", in_out='OUTPUT')

            # Store boolean nodes so we can link them later.
            boolean_nodes: List[GeometryNodeMeshBoolean] = []

            for index, boolean_modifier in enumerate(boolean_modifiers):
                boolean_node: GeometryNodeMeshBoolean = node_group.nodes.new(GeometryNodeMeshBoolean.__name__)
                boolean_node.location = (index * 200, 0)
                boolean_node.label = 'Boolean' + (' (Exact)' if boolean_modifier.solver == 'EXACT' else '')  # Store the solver in the node label.
                boolean_node.operation = boolean_modifier.operation

                if NODE_SOLVER:
                    boolean_node.solver = 'FLOAT' if boolean_modifier.solver == 'FAST' else boolean_modifier.solver

                # Link the boolean node with the previous boolean node.
                if boolean_nodes:
                    socket_index = 0 if boolean_node.operation == 'DIFFERENCE' else 1
                    node_group.links.new(boolean_node.inputs[socket_index], boolean_nodes[-1].outputs[0])
                boolean_nodes.append(boolean_node)

                object_node: GeometryNodeObjectInfo = node_group.nodes.new(GeometryNodeObjectInfo.__name__)
                object_node.location = (index * 200 - 200, -200)
                object_node.transform_space = 'RELATIVE'
                object_node.inputs['Object'].default_value = boolean_modifier.object

                # Link the boolean node with the corresponding object node.
                node_group.links.new(boolean_node.inputs[1], object_node.outputs[-1])

                # Remove the original boolean modifier after conversion.
                object.modifiers.remove(boolean_modifier)

                # Increment the boolean count.
                self.converted_boolean_count += 1

            # Link the first and last boolean nodes with the input and output nodes.
            socket_index = 0 if boolean_nodes[0].operation == 'DIFFERENCE' else 1
            node_group.links.new(boolean_nodes[0].inputs[socket_index], input_node.outputs[0])
            node_group.links.new(output_node.inputs[0], boolean_nodes[-1].outputs[0])

            # I like to deselect all nodes.
            for node in node_group.nodes.values():
                node.select = False

            # Restore all modifiers that were after the last boolean.
            _restore_modifiers(object, stored_modifiers)

            # Increment the nodes count.
            self.converted_nodes_count += 1

    def execute_unstack(self, object: Object, items: List[BoolStackItem]):
        for item in items:
            if not item.select:
                continue

            # Get the geometry nodes modifier and associated node group for this item.
            nodes_modifier: NodesModifier = object.modifiers.get(item.name)
            node_group = nodes_modifier.node_group

            # Backup and remove all modifiers after the nodes modifier.
            stored_modifiers = _backup_modifiers(object, object.modifiers.find(nodes_modifier.name) + 1)

            for link in sorted(node_group.links.values(), key=lambda link: link.to_node.name):
                if isinstance(link.to_node, GeometryNodeMeshBoolean) and isinstance(link.from_node, GeometryNodeObjectInfo):
                    boolean_node: GeometryNodeMeshBoolean = link.to_node
                    object_node: GeometryNodeObjectInfo = link.from_node

                    boolean_modifier: BooleanModifier = object.modifiers.new('Boolean', 'BOOLEAN')
                    boolean_modifier.show_expanded = False  # Collapse created modifiers.
                    boolean_modifier.operation = boolean_node.operation
                    boolean_modifier.object = object_node.inputs['Object'].default_value

                    if NODE_SOLVER:
                        boolean_modifier.solver = version.bsolver_fast() if boolean_node.solver == 'FLOAT' else boolean_node.solver

                    else:
                        boolean_modifier.solver = 'EXACT' if ('EXACT' in boolean_node.label.upper()) else 'FAST'  # Restore the solver from the node label.
                    # Increment the boolean count.
                    self.converted_boolean_count += 1

            # Remove the nodes modifier and associated node group.
            object.modifiers.remove(nodes_modifier)
            bpy.data.node_groups.remove(node_group)

            # Restore all modifiers that were after the nodes modifier.
            _restore_modifiers(object, stored_modifiers)

            # Increment the nodes count.
            self.converted_nodes_count += 1


def _backup_modifiers(object: Object, index: int) -> List[Modifier]:
    '''Backup and remove all modifiers after the given index.'''
    stored_modifiers: List[Modifier] = []

    for modifier in object.modifiers[index:]:
        stored_modifiers.append(modifier_stored(modifier))
        object.modifiers.remove(modifier)

    return stored_modifiers


def _restore_modifiers(object: Object, modifiers: List[Modifier]):
    '''Restore backed up modifiers to the end of the stack.'''
    for modifier in modifiers:
        modifier_new(object, mod=modifier)


def _boolean_modifier_sets(object: Object, convert_exact_booleans: bool) -> List[List[BooleanModifier]]:
    '''Get sets of consecutive eligible boolean modifiers.'''
    boolean_modifiers: List[BooleanModifier] = []
    boolean_sets: List[List[BooleanModifier]] = []

    for modifier in object.modifiers:
        # Add eligible modifiers to the list.
        if _check_boolean_modifier(modifier, convert_exact_booleans):
            boolean_modifiers.append(modifier)

        # If the set is not empty, add it and clear the list.
        elif boolean_modifiers:
            boolean_sets.append(boolean_modifiers.copy())
            boolean_modifiers.clear()

    # If the last modifier was eligible, add its set too.
    if boolean_modifiers:
        boolean_sets.append(boolean_modifiers)

    return boolean_sets


def _check_boolean_modifier(modifier: BooleanModifier, convert_exact_booleans: bool = True) -> bool:
    '''Check whether this is a valid boolean modifier for our use case.'''
    if isinstance(modifier, BooleanModifier) and (modifier.operand_type == 'OBJECT') and (modifier.object is not None):
        return convert_exact_booleans or (modifier.solver != 'EXACT')

    return False


def _remove_dead_boolean_modifiers(object: Object):
    '''Remove boolean modifiers with no cutter from this object.'''
    for modifier in object.modifiers.values():
        if isinstance(modifier, BooleanModifier):
            if modifier.operand_type == 'OBJECT':
                if modifier.object is None:
                    object.modifiers.remove(modifier)

            elif modifier.operand_type == 'COLLECTION':
                if modifier.collection is None:
                    object.modifiers.remove(modifier)


def _nodes_modifiers(object: Object) -> List[NodesModifier]:
    '''Get geometry nodes modifiers from the given object.'''
    nodes_modifiers: List[NodesModifier] = []

    for modifier in object.modifiers:
        if _check_nodes_modifier(modifier):
            nodes_modifiers.append(modifier)

    return nodes_modifiers


def _check_nodes_modifier(modifier: NodesModifier) -> bool:
    '''Check whether this is a valid nodes modifier for our use case.'''
    return isinstance(modifier, NodesModifier) and (modifier.node_group is not None)