Add CAD-style cross selection as the default for Bonsai tools

Introduce bim.cross_select, a Rhino/CAD-style box selection operator, and route
every Bonsai workspace tool's selection through it. Dragging the box left-to-right
selects only fully-enclosed elements (window); right-to-left selects anything the
box touches (crossing). Works in Object and Edit Mesh modes; plain click, Shift
(add) and Ctrl (subtract) behave as expected, and clicking empty space deselects.

Selection logic is ported from the GPL-3.0 Blender-Cross-Select add-on
(RARA, CYX, Witty.Ming, Shuimeng), reduced to box-only mode and adapted to read
its colors and line width from Bonsai's add-on preferences.

A new "Cross Select" add-on preference (CrossSelectPreferences) toggles the
behavior. Because a WorkSpaceTool's bl_keymap is baked at import, the shared
selection keymap is split into cross-select vs native variants and applied via
apply_cross_select_preference(), which rebuilds each tool's keymap and
re-registers all selection tools in place. Toggling the preference re-applies
live via a deferred timer, and the saved preference is applied on startup. When
disabled, tools fall back to Blender's native view3d.select_box / view3d.select.

- tool/blender.py: split native/cross-select keymaps, pref-driven
  get_default_selection_keypmap, apply_cross_select_preference re-registration
- bim/module/model/workspace.py: bim.cross_select operator and geometry/draw helpers
- bim/ui.py: CrossSelectPreferences group, preferences UI, live-toggle callback
- bim/__init__.py: register preference group, apply saved preference on startup
- bim/module/model/__init__.py: register CrossSelect operator

Generated with the assistance of an AI coding tool.
This commit is contained in:
Ryan Schultz
2026-06-07 17:33:34 -05:00
parent c83b4eb69f
commit 5f64ffee3f
5 changed files with 585 additions and 22 deletions
+5
View File
@@ -176,6 +176,7 @@ classes = [
# fields. Driven by ``tool.Parametric.EDIT_TYPES``. # fields. Driven by ``tool.Parametric.EDIT_TYPES``.
*_parametric_gizmo_preference_classes(), *_parametric_gizmo_preference_classes(),
ui.GizmoPreferences, ui.GizmoPreferences,
ui.CrossSelectPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below) # ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel # Tabs panel
ui.BIM_PT_tabs, ui.BIM_PT_tabs,
@@ -333,6 +334,10 @@ def register():
tool.Blender.ensure_bin_in_path() tool.Blender.ensure_bin_in_path()
# RestrictedContext doesn't allow accessing scene attribute, postpone it for a bit. # 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) 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(): def unregister():
@@ -71,6 +71,7 @@ classes = (
product.MirrorElements, product.MirrorElements,
product.SetActiveType, product.SetActiveType,
workspace.Hotkey, workspace.Hotkey,
workspace.CrossSelect,
workspace.BIM_MT_add_representation_item, workspace.BIM_MT_add_representation_item,
wall.AddWallsFromSlab, wall.AddWallsFromSlab,
wall.AlignWall, wall.AlignWall,
@@ -17,12 +17,18 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import os import os
import bmesh
import gpu
import numpy as np
from functools import partial from functools import partial
from typing import Optional, Union from typing import Optional, Union
import bpy import bpy
import bpy.utils.previews import bpy.utils.previews
from bpy.types import Menu, WorkSpaceTool 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.core.model as core
import bonsai.tool as tool 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 custom_icon_previews = None
display_mode = None display_mode = None
+76
View File
@@ -473,6 +473,68 @@ class GizmoPreferences(bpy.types.PropertyGroup):
wall: GizmoPreferencesWall 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): class DocPreferences(bpy.types.PropertyGroup):
sheets_dir: StringProperty( sheets_dir: StringProperty(
default=os.path.join("sheets") + os.path.sep, 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", description="Code that will be evaluated to generate occurrence name for CUSTOM occurrence name style",
) )
gizmos: bpy.props.PointerProperty(type=GizmoPreferences) gizmos: bpy.props.PointerProperty(type=GizmoPreferences)
cross_select: bpy.props.PointerProperty(type=CrossSelectPreferences)
def update_data_dir(self, context: bpy.types.Context) -> None: def update_data_dir(self, context: bpy.types.Context) -> None:
import bonsai.bim.schema import bonsai.bim.schema
@@ -855,6 +918,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"] occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
occurrence_name_function: str occurrence_name_function: str
gizmos: GizmoPreferences gizmos: GizmoPreferences
cross_select: CrossSelectPreferences
data_dir: str data_dir: str
cache_dir: str cache_dir: str
pset_dir: str pset_dir: str
@@ -917,6 +981,18 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
self.draw_gizmo_parameters, 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: def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.label(text="Toggle visibility of gizmos in editing mode") layout.label(text="Toggle visibility of gizmos in editing mode")
box = layout.box() box = layout.box()
+109 -22
View File
@@ -753,26 +753,12 @@ class Blender(bonsai.core.tool.Blender):
pass pass
@classmethod @classmethod
def get_default_selection_keypmap(cls) -> tuple: def get_native_selection_keymap(cls) -> tuple:
"""keymap to replicate default blender selection behaviour with click and box selection""" """Blender's default click + box selection keymap (used when Cross Select is off)."""
# code below comes from blender_default.py which is part of default blender scripts licensed under GPL v2 # Data from blender_default.py (GPL v2): the items of km_3d_view_tool_select_box()
# https://github.com/blender/blender/blob/master/release/scripts/presets/keyconfig/keymap_data/blender_default.py # and km_3d_view_tool_select(select_mouse="LEFTMOUSE"). See git history for the
# the code is the data from evaluating km_3d_view_tool_select() and km_3d_view_tool_select_box() # console snippet to regenerate these if Blender's defaults ever change.
# return (
# 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
("view3d.select_box", {"type": "LEFTMOUSE", "value": "CLICK_DRAG"}, None), ("view3d.select_box", {"type": "LEFTMOUSE", "value": "CLICK_DRAG"}, None),
( (
"view3d.select_box", "view3d.select_box",
@@ -789,7 +775,6 @@ class Blender(bonsai.core.tool.Blender):
{"type": "LEFTMOUSE", "value": "CLICK_DRAG", "shift": True, "ctrl": True}, {"type": "LEFTMOUSE", "value": "CLICK_DRAG", "shift": True, "ctrl": True},
{"properties": [("mode", "AND")]}, {"properties": [("mode", "AND")]},
), ),
# left-click selection keymap
("view3d.select", {"type": "LEFTMOUSE", "value": "PRESS"}, {"properties": [("deselect_all", True)]}), ("view3d.select", {"type": "LEFTMOUSE", "value": "PRESS"}, {"properties": [("deselect_all", True)]}),
( (
"view3d.select", "view3d.select",
@@ -797,7 +782,109 @@ class Blender(bonsai.core.tool.Blender):
{"properties": [("toggle", True)]}, {"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 = { KEY_MODIFIERS = {
"A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"), "A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"),