diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index d9055d1f79..13a0ecf582 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -176,6 +176,7 @@ classes = [ # fields. Driven by ``tool.Parametric.EDIT_TYPES``. *_parametric_gizmo_preference_classes(), ui.GizmoPreferences, + ui.CrossSelectPreferences, # ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below) # Tabs panel ui.BIM_PT_tabs, @@ -333,6 +334,10 @@ def register(): tool.Blender.ensure_bin_in_path() # RestrictedContext doesn't allow accessing scene attribute, postpone it for a bit. bpy.app.timers.register(tool.Blender.setup_user_data_dir, first_interval=0.1) + # Tools are imported (and their bl_keymap baked) before preferences exist, so they + # default to the Cross Select keymap. Once prefs are available, apply the saved + # preference (no-op unless the user disabled Cross Select). + bpy.app.timers.register(tool.Blender.apply_cross_select_preference, first_interval=0.1) def unregister(): diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 211de03879..883dbba243 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -71,6 +71,7 @@ classes = ( product.MirrorElements, product.SetActiveType, workspace.Hotkey, + workspace.CrossSelect, workspace.BIM_MT_add_representation_item, wall.AddWallsFromSlab, wall.AlignWall, diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 0d9e6305ad..00bf85b730 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -17,12 +17,18 @@ # along with Bonsai. If not, see . import os +import bmesh +import gpu +import numpy as np from functools import partial from typing import Optional, Union import bpy import bpy.utils.previews from bpy.types import Menu, WorkSpaceTool +from bpy_extras import view3d_utils +from gpu_extras.batch import batch_for_shader +from mathutils import Vector import bonsai.core.model as core import bonsai.tool as tool @@ -1488,5 +1494,393 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): ) +# ============================================================================== +# CAD/Rhino-style box selection ("Cross Select"). +# +# The selection logic below is ported from the GPL-3.0 "Blender Cross Select" +# add-on by RARA, CYX, Witty.Ming and Shuimeng +# (https://github.com/theoryshaw/Blender-Cross-Select), reduced to box-only mode +# and adapted to read its settings from Bonsai's add-on preferences. It is wired +# into every Bonsai tool via ``tool.Blender.get_default_selection_keypmap()``. +# +# Dragging the box left-to-right performs a "window" selection (only elements +# fully enclosed by the box are selected); dragging right-to-left performs a +# "crossing" selection (anything the box touches is selected). +# ============================================================================== + + +def _cs_is_point_in_rect(point: tuple[float, float], rect: tuple[tuple[float, float], tuple[float, float]]) -> bool: + x, y = point + return rect[0][0] <= x <= rect[1][0] and rect[0][1] <= y <= rect[1][1] + + +def _cs_ccw(a, b, c) -> bool: + return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0]) + + +def _cs_segments_intersect(a, b, c, d) -> bool: + return _cs_ccw(a, c, d) != _cs_ccw(b, c, d) and _cs_ccw(a, b, c) != _cs_ccw(a, b, d) + + +def _cs_is_segment_intersecting_rect(p1, p2, rect) -> bool: + min_x, min_y = rect[0] + max_x, max_y = rect[1] + if max(p1.x, p2.x) < min_x or min(p1.x, p2.x) > max_x or max(p1.y, p2.y) < min_y or min(p1.y, p2.y) > max_y: + return False + r1, r2, r3, r4 = (min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y) + line_start, line_end = (p1.x, p1.y), (p2.x, p2.y) + return ( + _cs_segments_intersect(line_start, line_end, r1, r2) + or _cs_segments_intersect(line_start, line_end, r2, r3) + or _cs_segments_intersect(line_start, line_end, r3, r4) + or _cs_segments_intersect(line_start, line_end, r4, r1) + ) + + +def _cs_make_3d_to_region_2d(verts, matrix_world, region, region_data, use_smart_sampling=True) -> list[Vector]: + """Batch-project a sequence of 3D vertices to 2D region coordinates using numpy.""" + vlen = len(verts) + if vlen == 0: + return [] + perspective_matrix = region_data.perspective_matrix + if use_smart_sampling: + step = max(1, 1 + ((vlen - 1) // 1000) * 10) + coords_3d = np.array([verts[i].co.to_tuple() for i in range(0, vlen, step)]) + else: + coords_3d = np.array([v.co.to_tuple() for v in verts]) + coords_3d = np.hstack((coords_3d, np.ones((coords_3d.shape[0], 1)))) + coords_3d = np.dot(coords_3d, np.array(matrix_world.transposed())) + coords_2d = np.dot(coords_3d, np.array(perspective_matrix.transposed())) + coords_2d /= coords_2d[:, 3].reshape(-1, 1) + coords_2d[:, 0] = (coords_2d[:, 0] + 1) * 0.5 * region.width + coords_2d[:, 1] = (coords_2d[:, 1] + 1) * 0.5 * region.height + return [Vector((co[0], co[1])) for co in coords_2d] + + +def _cs_get_sampled_coords(obj, context) -> list[Vector]: + if obj.type == "MESH" and obj.data.vertices: + return _cs_make_3d_to_region_2d( + obj.data.vertices, obj.matrix_world, context.region, context.region_data, use_smart_sampling=True + ) + co = view3d_utils.location_3d_to_region_2d(context.region, context.region_data, obj.matrix_world @ Vector((0, 0, 0))) + return [co] if co else [] + + +def _cs_is_object_in_rect(obj, context, rect) -> bool: + """True when every sampled vertex of ``obj`` projects inside ``rect`` (window match).""" + coords = _cs_get_sampled_coords(obj, context) + if not coords: + return False + return all(_cs_is_point_in_rect((co.x, co.y), rect) for co in coords) + + +def _cs_draw_callback_px(operator, context) -> None: + if not operator.is_dragging: + return + cs_prefs = operator.cs_prefs + try: + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + except Exception: + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + + gpu.state.blend_set("ALPHA") + gpu.state.line_width_set(cs_prefs.line_width) + + if not operator.enabled: + # Disabled: selection is native, so draw a neutral (non-directional) box. + color = (1.0, 1.0, 1.0, 1.0) + line_style = "DASHED" + elif operator.select_mode == "FULLY": + color = (*cs_prefs.fully_color, 1.0) + line_style = "SOLID" + else: + color = (*cs_prefs.partial_color, 1.0) + line_style = "DASHED" + + min_x, max_x, min_y, max_y = operator.box_path + vertices = ( + (min_x, min_y), + (max_x, min_y), + (max_x, max_y), + (min_x, max_y), + (min_x, min_y), + ) + shader.bind() + shader.uniform_float("color", color) + if line_style == "SOLID": + batch_for_shader(shader, "LINE_STRIP", {"pos": vertices}).draw(shader) + else: + dash_length, gap_length = 10, 5 + dash_vertices = [] + for i in range(len(vertices) - 1): + start = Vector(vertices[i]) + end = Vector(vertices[i + 1]) + line_dir = (end - start).normalized() + line_length = (end - start).length + num_dashes = int(line_length // (dash_length + gap_length)) + for j in range(num_dashes): + dash_start = start + line_dir * (j * (dash_length + gap_length)) + dash_end = dash_start + line_dir * dash_length + if (dash_end - start).length > line_length: + dash_end = end + dash_vertices.extend([dash_start, dash_end]) + remaining = line_length - num_dashes * (dash_length + gap_length) + if remaining > 0: + dash_start = start + line_dir * (num_dashes * (dash_length + gap_length)) + dash_end = dash_start + line_dir * min(dash_length, remaining) + dash_vertices.extend([dash_start, dash_end]) + if dash_vertices: + batch_for_shader(shader, "LINES", {"pos": dash_vertices}).draw(shader) + + gpu.state.line_width_set(1.0) + gpu.state.blend_set("NONE") + + +class CrossSelect(bpy.types.Operator): + bl_idname = "bim.cross_select" + bl_label = "Cross Select" + bl_options = {"REGISTER", "BLOCKING", "UNDO"} + bl_description = ( + "CAD/Rhino-style box selection.\n\n" + "Drag left to right: window (only fully-enclosed elements)\n" + "Drag right to left: crossing (anything the box touches)\n\n" + "Shift: add to selection\nCtrl: subtract from selection" + ) + + DRAG_THRESHOLD = 5 + + def invoke(self, context, event): + self.cs_prefs = tool.Blender.get_addon_preferences().cross_select + self.enabled = self.cs_prefs.enabled + self.start_mouse = (event.mouse_region_x, event.mouse_region_y) + self.end_mouse = self.start_mouse + self.is_dragging = False + self.select_mode = "FULLY" # "FULLY" (window) or "HALF" (crossing) + self.operation = "SET" + self.draw_handle = None + self.box_path = (0, 0, 0, 0) # (min_x, max_x, min_y, max_y) + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + + def modal(self, context, event): + if event.shift: + self.operation = "ADD" + elif event.ctrl: + self.operation = "SUB" + else: + self.operation = "SET" + + if event.type == "MOUSEMOVE": + if not self.is_dragging: + delta = Vector((event.mouse_region_x, event.mouse_region_y)) - Vector(self.start_mouse) + if delta.length > self.DRAG_THRESHOLD: + self._start_dragging(context) + if self.is_dragging: + self._update_drag_position(event) + context.area.tag_redraw() + + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + if self.is_dragging: + self._finish_box_select(context) + else: + self._handle_single_click(context, event) + return {"FINISHED"} + + if event.type in {"RIGHTMOUSE", "ESC"}: + self._cleanup_drawing() + context.area.tag_redraw() + return {"CANCELLED"} + + return {"RUNNING_MODAL"} + + def _start_dragging(self, context): + self.is_dragging = True + self.draw_handle = bpy.types.SpaceView3D.draw_handler_add( + _cs_draw_callback_px, (self, context), "WINDOW", "POST_PIXEL" + ) + context.area.tag_redraw() + + def _update_drag_position(self, event): + self.end_mouse = (event.mouse_region_x, event.mouse_region_y) + # Dragging rightward selects only fully-enclosed elements (window), + # dragging leftward selects anything touched (crossing). + self.select_mode = "FULLY" if self.end_mouse[0] > self.start_mouse[0] else "HALF" + x1, y1 = self.start_mouse + x2, y2 = self.end_mouse + self.box_path = (min(x1, x2), max(x1, x2), min(y1, y2), max(y1, y2)) + + def _cleanup_drawing(self): + if self.draw_handle: + bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle, "WINDOW") + self.draw_handle = None + + def _finish_box_select(self, context): + self._cleanup_drawing() + min_x, max_x, min_y, max_y = self.box_path + + # Cross-select disabled: reproduce Blender's native box selection. + if not self.enabled: + bpy.ops.view3d.select_box( + wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode=self.operation + ) + context.area.tag_redraw() + return + + if context.mode == "OBJECT": + self._process_selection_object(context) + elif context.mode == "EDIT_MESH": + self._process_selection_edit(context) + context.area.tag_redraw() + + # --- Object mode --------------------------------------------------------- + def _process_selection_object(self, context): + original_selection = set(context.selected_objects) + + # Use the native box select to collect everything the box touches. + bpy.ops.object.select_all(action="DESELECT") + min_x, max_x, min_y, max_y = self.box_path + bpy.ops.view3d.select_box(wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode="ADD") + touched_objects = set(context.selected_objects) + + # Filter touched objects by the window/crossing rule. + rect = ((min_x, min_y), (max_x, max_y)) + if self.select_mode == "HALF": + valid_selection = touched_objects + else: + valid_selection = {obj for obj in touched_objects if _cs_is_object_in_rect(obj, context, rect)} + + if self.operation == "SET": + final_selected = valid_selection + elif self.operation == "ADD": + final_selected = original_selection | valid_selection + else: # SUB + final_selected = original_selection - valid_selection + + bpy.ops.object.select_all(action="DESELECT") + for obj in final_selected: + obj.select_set(True) + + # --- Edit mesh mode ------------------------------------------------------ + def _process_selection_edit(self, context): + if hasattr(context, "objects_in_mode_unique_data"): + edit_objs = [obj for obj in context.objects_in_mode_unique_data if obj.type == "MESH"] + elif hasattr(context, "objects_in_mode"): + edit_objs = [obj for obj in context.objects_in_mode if obj.type == "MESH"] + else: + obj = context.edit_object + edit_objs = [obj] if obj and obj.type == "MESH" else [] + if not edit_objs: + return + + is_vert, is_edge, is_face = context.tool_settings.mesh_select_mode + min_x, max_x, min_y, max_y = self.box_path + + # Vertex mode (and crossing face mode) match Blender's native box select. + if is_vert or (is_face and self.select_mode == "HALF"): + bpy.ops.view3d.select_box( + wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode=self.operation + ) + return + + element_key = "edges" if is_edge else "faces" + bm_cache = {} + original = {} + for obj in edit_objs: + bm = bmesh.from_edit_mesh(obj.data) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + bm.faces.ensure_lookup_table() + bm_cache[obj] = bm + elems = bm.edges if is_edge else bm.faces + original[obj] = {ele for ele in elems if ele.select} + + # Collect every element the box touches via native box select on faces+edges. + bpy.ops.mesh.select_all(action="DESELECT") + for temp_select_mode in ((False, False, True), (False, True, False)): + context.tool_settings.mesh_select_mode = temp_select_mode + bpy.ops.view3d.select_box(wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode="ADD") + context.tool_settings.mesh_select_mode = (is_vert, is_edge, is_face) + + rect = ((min_x, min_y), (max_x, max_y)) + valid = {} + for obj, bm in bm_cache.items(): + elems = bm.edges if is_edge else bm.faces + touched = {ele for ele in elems if ele.select} + valid[obj] = {ele for ele in touched if self._is_element_valid(context, obj, rect, ele, element_key)} + + final = {} + for obj in edit_objs: + if self.operation == "SET": + final[obj] = valid[obj] + elif self.operation == "ADD": + final[obj] = original[obj] | valid[obj] + else: # SUB + final[obj] = original[obj] - valid[obj] + + bpy.ops.mesh.select_all(action="DESELECT") + if is_edge: + for obj, bm in bm_cache.items(): + for e in final[obj]: + e.select = True + for f in e.link_faces: + if f.select: + continue + if all(edge in final[obj] for edge in f.edges): + f.select = True + bmesh.update_edit_mesh(obj.data) + else: + for obj, bm in bm_cache.items(): + for f in final[obj]: + f.select = True + bmesh.update_edit_mesh(obj.data) + + def _is_element_valid(self, context, obj, rect, ele, ele_type): + region = context.region + rv3d = context.region_data + matrix = obj.matrix_world + vert_2d = [] + all_inside = True + any_inside = False + for v in ele.verts: + co_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, matrix @ v.co) + if not co_2d: + all_inside = False + continue + vert_2d.append(co_2d) + if _cs_is_point_in_rect((co_2d.x, co_2d.y), rect): + any_inside = True + else: + all_inside = False + + if self.select_mode == "FULLY": + return all_inside + if any_inside: + return True + # Crossing: element edges intersecting the box border also count. + n = len(vert_2d) + if ele_type == "edges": + return n == 2 and _cs_is_segment_intersecting_rect(vert_2d[0], vert_2d[1], rect) + for i in range(n): + if _cs_is_segment_intersecting_rect(vert_2d[i], vert_2d[(i + 1) % n], rect): + return True + return False + + def _handle_single_click(self, context, event): + # A plain click uses Blender's native pick selection. ``deselect_all`` clears + # the selection when clicking empty space (in both Object and Edit Mesh modes), + # matching Blender's default selection tool; Shift toggles and Ctrl deselects. + try: + bpy.ops.view3d.select( + "INVOKE_DEFAULT", + extend=event.shift, + deselect=event.ctrl, + toggle=False, + deselect_all=not (event.shift or event.ctrl), + location=(event.mouse_region_x, event.mouse_region_y), + ) + except Exception: + pass + + custom_icon_previews = None display_mode = None diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 02934f701d..4bacfd8655 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -473,6 +473,68 @@ class GizmoPreferences(bpy.types.PropertyGroup): wall: GizmoPreferencesWall +def _apply_cross_select_pref() -> None: + """Timer callback: re-register Bonsai tools so the Cross Select toggle takes effect.""" + tool.Blender.apply_cross_select_preference() + return None # run once + + +def _update_cross_select_enabled(self: "CrossSelectPreferences", context: bpy.types.Context) -> None: + # Re-registering tools mid property-update is unsafe, so defer to a one-shot timer. + if not bpy.app.background and not bpy.app.timers.is_registered(_apply_cross_select_pref): + bpy.app.timers.register(_apply_cross_select_pref, first_interval=0.0) + + +class CrossSelectPreferences(bpy.types.PropertyGroup): + """CAD/Rhino-style box selection settings for Bonsai tools. + + When enabled, every Bonsai workspace tool uses ``bim.cross_select`` instead of + Blender's native box/click selection: dragging the box left-to-right selects only + fully-enclosed elements (window), right-to-left selects anything touched (crossing). + """ + + enabled: BoolProperty( + name="Cross Select", + default=True, + description=( + "Use CAD/Rhino-style box selection in Bonsai tools.\n" + "Drag left to right to select only fully-enclosed elements (window).\n" + "Drag right to left to select anything the box touches (crossing).\n" + "Disable to use Blender's native box/click selection" + ), + update=_update_cross_select_enabled, + ) + fully_color: bpy.props.FloatVectorProperty( + name="Window Match Color", + subtype="COLOR", + default=(0.2, 0.6, 1.0), # blue + min=0.0, + max=1.0, + description="Box color when dragging left to right (fully-enclosed / window match)", + ) + partial_color: bpy.props.FloatVectorProperty( + name="Crossing Match Color", + subtype="COLOR", + default=(1.0, 0.4, 0.1), # orange + min=0.0, + max=1.0, + description="Box color when dragging right to left (partial / crossing match)", + ) + line_width: bpy.props.IntProperty( + name="Line Width", + default=2, + min=0, + max=20, + description="Width of the selection box border", + ) + + if TYPE_CHECKING: + enabled: bool + fully_color: tuple[float, float, float] + partial_color: tuple[float, float, float] + line_width: int + + class DocPreferences(bpy.types.PropertyGroup): sheets_dir: StringProperty( default=os.path.join("sheets") + os.path.sep, @@ -752,6 +814,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): description="Code that will be evaluated to generate occurrence name for CUSTOM occurrence name style", ) gizmos: bpy.props.PointerProperty(type=GizmoPreferences) + cross_select: bpy.props.PointerProperty(type=CrossSelectPreferences) def update_data_dir(self, context: bpy.types.Context) -> None: import bonsai.bim.schema @@ -855,6 +918,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"] occurrence_name_function: str gizmos: GizmoPreferences + cross_select: CrossSelectPreferences data_dir: str cache_dir: str pset_dir: str @@ -917,6 +981,18 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): self.draw_gizmo_parameters, ) + layout.prop(self.cross_select, "enabled") + if self.cross_select.enabled: + box = layout.box() + box.label(text="Drag left to right: window (only fully-enclosed elements)", icon="FORWARD") + box.label(text="Drag right to left: crossing (anything the box touches)", icon="BACK") + row = box.row(align=True) + row.prop(self.cross_select, "fully_color", text="") + row.label(text="Window Match") + row.prop(self.cross_select, "partial_color", text="") + row.label(text="Crossing Match") + box.prop(self.cross_select, "line_width") + def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.label(text="Toggle visibility of gizmos in editing mode") box = layout.box() diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index c94ec38d72..f95622cc1e 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -753,26 +753,12 @@ class Blender(bonsai.core.tool.Blender): pass @classmethod - def get_default_selection_keypmap(cls) -> tuple: - """keymap to replicate default blender selection behaviour with click and box selection""" - # code below comes from blender_default.py which is part of default blender scripts licensed under GPL v2 - # https://github.com/blender/blender/blob/master/release/scripts/presets/keyconfig/keymap_data/blender_default.py - # the code is the data from evaluating km_3d_view_tool_select() and km_3d_view_tool_select_box() - # - # You can run the snippet below in Blender console - # to regenerate those keybindings in case of errors in the future - # ``` - # import os - # version = ".".join(bpy.app.version_string.split(".")[:2]) - # fl = os.path.join(os.getcwd(), version, "scripts/presets/keyconfig/keymap_data/blender_default.py") - # def_keymap = bpy.utils.execfile(fl) - # params = def_keymap.Params - # box_keymap = def_keymap.km_3d_view_tool_select_box(def_keymap.Params(), fallback=None)[2]["items"] - # click_keymap = def_keymap.km_3d_view_tool_select(def_keymap.Params(select_mouse="LEFTMOUSE"), fallback=None)[2]["items"] - # ``` - # https://docs.blender.org/api/current/bpy.types.KeyMapItems.html - keymap = ( - # box selection keymap + def get_native_selection_keymap(cls) -> tuple: + """Blender's default click + box selection keymap (used when Cross Select is off).""" + # Data from blender_default.py (GPL v2): the items of km_3d_view_tool_select_box() + # and km_3d_view_tool_select(select_mouse="LEFTMOUSE"). See git history for the + # console snippet to regenerate these if Blender's defaults ever change. + return ( ("view3d.select_box", {"type": "LEFTMOUSE", "value": "CLICK_DRAG"}, None), ( "view3d.select_box", @@ -789,7 +775,6 @@ class Blender(bonsai.core.tool.Blender): {"type": "LEFTMOUSE", "value": "CLICK_DRAG", "shift": True, "ctrl": True}, {"properties": [("mode", "AND")]}, ), - # left-click selection keymap ("view3d.select", {"type": "LEFTMOUSE", "value": "PRESS"}, {"properties": [("deselect_all", True)]}), ( "view3d.select", @@ -797,7 +782,109 @@ class Blender(bonsai.core.tool.Blender): {"properties": [("toggle", True)]}, ), ) - return keymap + + @classmethod + def get_cross_select_keymap(cls) -> tuple: + """CAD/Rhino-style box selection keymap, routed through ``bim.cross_select``. + + A single ``LEFTMOUSE`` ``PRESS`` binding lets the modal operator decide between a + click and a box drag itself (drag left-to-right = window, right-to-left = crossing), + reading Shift/Ctrl to add or subtract from the selection. + """ + return ( + ("bim.cross_select", {"type": "LEFTMOUSE", "value": "PRESS"}, {"properties": []}), + ("bim.cross_select", {"type": "LEFTMOUSE", "value": "PRESS", "shift": True}, {"properties": []}), + ("bim.cross_select", {"type": "LEFTMOUSE", "value": "PRESS", "ctrl": True}, {"properties": []}), + ( + "bim.cross_select", + {"type": "LEFTMOUSE", "value": "PRESS", "shift": True, "ctrl": True}, + {"properties": []}, + ), + ) + + @classmethod + def is_cross_select_enabled(cls) -> bool: + try: + return bool(cls.get_addon_preferences().cross_select.enabled) + except (KeyError, AttributeError): + # Preferences are not registered yet (e.g. at tool import/registration time). + # Default to Cross Select; the saved preference is applied on startup via + # apply_cross_select_preference(). + return True + + @classmethod + def get_default_selection_keypmap(cls) -> tuple: + """Selection keymap shared by every Bonsai tool. + + Returns the Cross Select keymap when the add-on preference is enabled, otherwise + Blender's native selection keymap. ``bl_keymap`` is evaluated once when the tool + classes are imported, so toggling the preference re-applies the keymap live via + :meth:`apply_cross_select_preference`. + """ + return cls.get_cross_select_keymap() if cls.is_cross_select_enabled() else cls.get_native_selection_keymap() + + # Number of leading ``bl_keymap`` entries that make up the selection keymap, keyed by the + # operator idname of the first entry. Lets us swap the selection prefix in place while + # preserving each tool's own (hotkey) entries. + _SELECTION_PREFIX_LENGTHS = {"bim.cross_select": 4, "view3d.select_box": 6} + + @classmethod + def _iter_selection_tools(cls): + """Yield ``(tool_cls, after, separator, group)`` for every Bonsai WorkSpaceTool that + uses the shared selection keymap, in registration order so re-registration preserves + the toolbar layout. + + NOTE: keep in sync with each module's ``register()`` tool registration. The model + tools are read from ``model.tools``; the single-tool modules are listed explicitly. + """ + from bonsai.bim.module import project, model, cad, drawing, spatial, structural, covering + + yield (project.workspace.ExploreTool, {"builtin.transform"}, True, False) + for td in model.tools: + yield (td.tool, td.after, td.separator, td.group) + yield (cad.workspace.CadTool, {"builtin.transform"}, True, False) + yield (drawing.workspace.AnnotationTool, {"bim.bim_tool"}, True, False) + yield (spatial.workspace.SpatialTool, {"bim.annotation_tool"}, False, False) + yield (structural.workspace.StructuralTool, {"bim.spatial_tool"}, False, False) + yield (covering.workspace.CoveringTool, {"bim.wall_tool"}, False, False) + + @classmethod + def _tool_extra_keymap(cls, tool_cls) -> tuple: + """Return ``tool_cls``'s own (non-selection) ``bl_keymap`` entries.""" + km = tuple(tool_cls.bl_keymap) + n = cls._SELECTION_PREFIX_LENGTHS.get(km[0][0], 0) if km else 0 + return km[n:] + + @classmethod + def apply_cross_select_preference(cls) -> None: + """Rebuild every Bonsai tool's selection keymap from the current preference and + re-register the tools so the Cross Select toggle takes effect without a restart. + + Safe to call repeatedly: it no-ops when the tools already carry the desired keymap. + """ + if bpy.app.background: + return + selection_keymap = tuple(cls.get_default_selection_keypmap()) + tools = list(cls._iter_selection_tools()) + if not tools: + return + + desired_op = selection_keymap[0][0] if selection_keymap else None + current = tuple(tools[0][0].bl_keymap) + if current and current[0][0] == desired_op: + return # already applied + + # Capture each tool's own entries before mutating any ``bl_keymap`` (model subclasses + # share BimTool's inherited keymap, so this must be done up front). + extras = {tool_cls: cls._tool_extra_keymap(tool_cls) for tool_cls, *_ in tools} + for tool_cls, *_ in reversed(tools): + try: + bpy.utils.unregister_tool(tool_cls) + except Exception: + pass + for tool_cls, after, separator, group in tools: + tool_cls.bl_keymap = selection_keymap + extras[tool_cls] + bpy.utils.register_tool(tool_cls, after=after, separator=separator, group=group) KEY_MODIFIERS = { "A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"),