diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 00bf85b730..aa655dd3cc 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -1650,6 +1650,14 @@ class CrossSelect(bpy.types.Operator): 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) @@ -1662,6 +1670,47 @@ class CrossSelect(bpy.types.Operator): 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" diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index f95622cc1e..cab2c1e1fa 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -823,9 +823,9 @@ class Blender(bonsai.core.tool.Blender): """ 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. + # 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 @@ -849,11 +849,23 @@ class Blender(bonsai.core.tool.Blender): 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.""" + 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) - n = cls._SELECTION_PREFIX_LENGTHS.get(km[0][0], 0) if km else 0 - return km[n:] + 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: @@ -871,19 +883,24 @@ class Blender(bonsai.core.tool.Blender): 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: + # 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). - extras = {tool_cls: cls._tool_extra_keymap(tool_cls) for tool_cls, *_ in tools} + 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: - tool_cls.bl_keymap = selection_keymap + extras[tool_cls] + 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 = {