mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-24 09:16:53 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user