Compare commits

...

2 Commits

Author SHA1 Message Date
Ryan Schultz 9f5e4d3949 Fix parametric dimension dot clicks broken by bim.cross_select BLOCKING modal
Blender fires all matching tool-keymap entries' invoke() even after an
earlier entry returned RUNNING_MODAL.  When cross-select is enabled,
bim.cross_select (bl_options BLOCKING) goes modal on every LMB press
alongside bim.click_nearest_dimension_anchor.  Its BLOCKING flag causes
it to win the RELEASE event, selecting the background object instead of
activating the anchor dot.

Fix: CrossSelect.invoke() now calls _near_dimension_dot() before going
modal.  If a parametric dimension anchor dot is within 15 px of the
cursor it returns PASS_THROUGH, yielding to ClickNearestDimensionAnchor.

Also fixes apply_cross_select_preference to preserve pre-selection
entries (e.g. ClickNearestDimensionAnchor) when rebuilding tool keymaps:
replaces _tool_extra_keymap (which assumed the selection block starts at
index 0) with _split_tool_keymap, which searches for the selection block
by operator name and returns (pre, post) slices independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:53:51 -05:00
Ryan Schultz 5f64ffee3f 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.
2026-06-07 17:33:34 -05:00
5 changed files with 651 additions and 22 deletions
+5
View File
@@ -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():
@@ -71,6 +71,7 @@ classes = (
product.MirrorElements,
product.SetActiveType,
workspace.Hotkey,
workspace.CrossSelect,
workspace.BIM_MT_add_representation_item,
wall.AddWallsFromSlab,
wall.AlignWall,
@@ -17,12 +17,18 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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,442 @@ 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):
# Yield to ClickNearestDimensionAnchor when a parametric dimension dot
# is near the cursor. Blender fires all matching tool-keymap entries'
# invoke() even after an earlier entry returned RUNNING_MODAL, so without
# this check our BLOCKING modal would win over click_nearest's modal and
# prevent the dot from turning blue.
if self._near_dimension_dot(context, event):
return {"PASS_THROUGH"}
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"}
@staticmethod
def _near_dimension_dot(context, event) -> bool:
"""Return True if the click is within 15 px of a parametric dimension anchor dot."""
try:
import ifcopenshell.util.element as _ue
from bpy_extras.view3d_utils import location_3d_to_region_2d
region = next(
(r for a in context.screen.areas if a.type == "VIEW_3D" for r in a.regions if r.type == "WINDOW"),
None,
)
rv3d = next(
(s.region_3d for a in context.screen.areas if a.type == "VIEW_3D" for s in a.spaces if s.type == "VIEW_3D"),
None,
)
if not region or not rv3d:
return False
cx = event.mouse_x - region.x
cy = event.mouse_y - region.y
r2 = 15 ** 2
for obj in context.scene.objects:
if obj.type != "CURVE" or not obj.visible_get():
continue
elem = tool.Ifc.get_entity(obj) if tool.Ifc.get() else None
if not elem or not elem.is_a("IfcAnnotation"):
continue
pset = _ue.get_pset(elem, "BBIM_Dimension")
if not pset or not pset.get("Anchors"):
continue
if not obj.data.splines:
continue
for pt in obj.data.splines[0].points:
sp = location_3d_to_region_2d(region, rv3d, obj.matrix_world @ pt.co.to_3d())
if sp and (cx - sp.x) ** 2 + (cy - sp.y) ** 2 < r2:
return True
except Exception:
pass
return False
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
+76
View File
@@ -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()
+126 -22
View File
@@ -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,126 @@ 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()
# Length of the selection block in bl_keymap, keyed by the operator idname of its
# first entry. Used to locate and replace the selection block during a rebuild
# while preserving any pre-selection or post-selection entries the tool declares.
_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 _split_tool_keymap(cls, tool_cls) -> tuple[tuple, tuple]:
"""Split bl_keymap into (pre_selection, post_selection), discarding the selection block.
Locates the selection block by finding the first entry whose op is in
``_SELECTION_PREFIX_LENGTHS``, then skips the declared number of entries.
Entries before the block become *pre_selection*; entries after become
*post_selection*. The caller inserts the desired selection keymap between them.
When no selection block is found (shouldn't happen in practice) all entries
are returned as post_selection so the rebuild still produces a valid keymap.
"""
km = tuple(tool_cls.bl_keymap)
sel_start = next((i for i, entry in enumerate(km) if entry[0] in cls._SELECTION_PREFIX_LENGTHS), None)
if sel_start is None:
return (), km
sel_len = cls._SELECTION_PREFIX_LENGTHS[km[sel_start][0]]
return km[:sel_start], km[sel_start + sel_len:]
@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)
# The selection block may be preceded by pre-selection entries (e.g. the
# AnnotationTool's ClickNearestDimensionAnchor), so search for it by op name
# rather than assuming it starts at position 0.
sel_start = next((i for i, entry in enumerate(current) if entry[0] in cls._SELECTION_PREFIX_LENGTHS), None)
if sel_start is not None and current[sel_start][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).
pre_post = {tool_cls: cls._split_tool_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:
pre, post = pre_post[tool_cls]
tool_cls.bl_keymap = pre + selection_keymap + post
bpy.utils.register_tool(tool_cls, after=after, separator=separator, group=group)
KEY_MODIFIERS = {
"A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"),