Highlight partner wall on link-toggle hover

Hovering a wall-junction link-toggle icon today only swaps the icon
shape — the user doesn't see which wall the click will disconnect from
until after they click. ATPATH (T-junction) configurations especially
make the partner ambiguous when multiple connections sit close together.

On hover, paint a wireframe bbox around the partner wall using the same
shader, constants and color the array module already established for
its layer-children highlight (POLYLINE_UNIFORM_COLOR, decorator_color_special,
line width 1.8, alpha 0.8). The line-width / alpha constants in decorator.py
are renamed from _ARRAY_LAYER_BBOX_LINE_* to _BBOX_HIGHLIGHT_LINE_* and
shared between draw_array_layer_children_bbox and the new
draw_wall_partner_bbox so the two highlights stay in lockstep.

The trigger lives in a new GizmoWallLinkToggle subclass in wall.py
which keeps the base gizmos.GizmoLinkToggle generic (per the
generic-naming convention for shared widgets). The subclass's draw()
calls super().draw(context) then on self.is_highlight outlines its
partner_obj via the shared decorator helper. Same trigger pattern as
GizmoArrayLayerIndicator.

Blender's Gizmo API exposes target_set_operator but no symmetric
getter, so the partner reference can't be read back from the bound
operator handle. Instead GizmoWallUnjoinSingle.position_gizmos
mirrors the resolved partner_obj onto each visible icon every frame
next to the existing other_wall_guid write — the icon's draw() reads
from its own __slots__-declared attribute.

A forward-compat AST test pins the contract: GizmoWallLinkToggle.draw
must reference is_highlight and call draw_wall_partner_bbox. Catches
the regression where someone tidies the draw() override into super()
or replaces the shared helper with an ad-hoc draw call.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-01 16:40:18 +02:00
parent 4cf34b69d2
commit f0aec7b38e
4 changed files with 95 additions and 5 deletions
@@ -108,6 +108,7 @@ classes = (
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
wall.JoinWallsIntersection,
wall.MergeWall,
@@ -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,
)
+36 -1
View File
@@ -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):
@@ -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."
)