diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index c60048a5db..9085e72ae7 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -108,6 +108,7 @@ classes = ( wall.GizmoWallFilletPreview, wall.GizmoWallFilletReedit, wall.GizmoWallJoinIntersection, + wall.GizmoWallLinkToggle, wall.GizmoWallUnjoinSingle, wall.JoinWallsIntersection, wall.MergeWall, diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index f45cd9b135..18c07ea57c 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -2233,8 +2233,8 @@ def draw_polyline_segments( gpu.state.blend_set("NONE") -_ARRAY_LAYER_BBOX_LINE_WIDTH = 1.8 -_ARRAY_LAYER_BBOX_LINE_ALPHA = 0.8 +_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8 +_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8 _ARRAY_LAYER_BBOX_MAX_CHILDREN = 200 @@ -2283,8 +2283,31 @@ def draw_array_layer_children_bbox( context, segments, color, - _ARRAY_LAYER_BBOX_LINE_ALPHA, - _ARRAY_LAYER_BBOX_LINE_WIDTH, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + ) + + +def draw_wall_partner_bbox( + context: bpy.types.Context, + partner_obj: bpy.types.Object, +) -> None: + """Paint a wireframe bbox around ``partner_obj`` in the same 3D pass. + Called inline from gizmo ``draw()`` methods so the highlight tracks the + hover cursor one-for-one — no POST_VIEW handler, no timing lag. + + Silently no-ops if the object has no bounding box (e.g. Empties).""" + segments = bbox_world_edges(partner_obj) + if not segments: + return + prefs = tool.Blender.get_addon_preferences() + color = prefs.decorator_color_special[:3] + draw_polyline_segments( + context, + segments, + color, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, ) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 016e36ddcd..ea1f938d0d 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -3546,6 +3546,36 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self.merge_icon.hide = True +class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo): + """Link-toggle glyph with a partner-wall highlight on hover. The owning + gizmo group writes the partner Blender object onto each icon every frame + via ``partner_obj``; on ``is_highlight`` the partner's bbox is outlined + inline so the user sees which wall the click will disconnect from + before committing. + + The partner reference is stashed on the gizmo instance rather than + read back from the bound operator handle because Blender's Gizmo API + exposes ``target_set_operator`` for binding but no symmetric getter.""" + + bl_idname = "VIEW3D_GT_wall_link_toggle" + __slots__ = ("partner_obj",) + + def setup(self) -> None: + super().setup() + self.partner_obj = None + + def draw(self, context: bpy.types.Context) -> None: + super().draw(context) + if not self.is_highlight: + return + partner = self.partner_obj + if partner is None: + return + from bonsai.bim.module.model.decorator import draw_wall_partner_bbox + + draw_wall_partner_bbox(context, partner) + + class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): """Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at every join location inferred from the wall's IfcRelConnectsPathElements inverse @@ -3600,7 +3630,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix self.unjoin_op_props = [] for _ in range(self.POOL_SIZE): icon = self.setup_icon_gizmo( - "VIEW3D_GT_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection" + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection" ) icon.hide = True self.unjoin_icons.append(icon) @@ -3652,6 +3682,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix # save/reload, and any sit-in-the-undo-stack interlude between dispatch # and execute. self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId + # Mirror the partner reference onto the icon itself so its draw() + # can outline the partner on hover without a Gizmo-side getter on + # the bound operator (the API exposes target_set_operator with + # no symmetric reader). + icon.partner_obj = other_obj class GizmoWallFilletPreview(bpy.types.GizmoGroup): diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py index db55f50cde..6a1067bcab 100644 --- a/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py @@ -28,6 +28,7 @@ rule is.""" import ast import inspect +import textwrap import pytest @@ -59,3 +60,33 @@ def test_iter_path_connections_uses_path_connectable_predicate(): "that strict predicate drops fillet-corner walls. Use " "is_path_connectable_wall instead." ) + + +def test_gizmo_wall_link_toggle_invokes_partner_bbox_helper(): + """The wall subclass must call draw_wall_partner_bbox when its hover + state is active. Without this contract the partner-wall highlight + silently regresses if someone "tidies" the draw() override away.""" + from bonsai.bim.module.model import wall as wall_module + + source = textwrap.dedent(inspect.getsource(wall_module.GizmoWallLinkToggle.draw)) + tree = ast.parse(source) + attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)} + call_names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Attribute): + call_names.add(node.func.attr) + elif isinstance(node.func, ast.Name): + call_names.add(node.func.id) + + assert "is_highlight" in attr_names, ( + "GizmoWallLinkToggle.draw must gate its highlight call on self.is_highlight — " + "without it the partner outline would draw every frame, not just on hover." + ) + assert "draw_wall_partner_bbox" in call_names, ( + "GizmoWallLinkToggle.draw must call draw_wall_partner_bbox to render the " + "partner outline. The shared composite in decorator.py is the canonical " + "trigger for this feature; replacing it with an ad-hoc draw call would " + "drift from the array-children bbox styling." + )