Clear wall-edit gizmos off click targets in plan view

In plan view world-Z collapses to zero on screen, so every wall-edit
icon anchored on the floor — the projected 3D cursor, wall endpoints,
wall-to-wall corners, IfcRelConnectsPathElements connection points —
projects onto the click target it represents. The result on a typical
extend / split / unjoin action: the icon sits on top of the cursor
crosshair (or the corner the user wants to click), defeating precise
positioning.

Add shared ``gizmo.top_down_clearance(context, billboard_rot)`` to
bim/module/drawing/gizmos.py: returns a screen-up Vector in top-down
view (cosine cone around world Z, matching ``is_view_top_down``) and a
zero Vector elsewhere, so call sites apply it unconditionally before
``billboarded_at``. Default distance 0.4 m aligns with the inter-icon
stack spacing already used by GizmoWallJoinIntersection so single
icons and stack bases land at consistent screen-up positions when
multiple groups render around the same wall endpoint.

Apply at the seven wall-edit anchor sites:

* GizmoWallEdition cursor stack (top-down branch only — non-top-down
  already stacks along world-Z at structural points clear of the
  cursor).
* GizmoWallExtendVertically (single icon at wall origin endpoint,
  active-object Z elevation).
* GizmoWallJoinIntersection corner stack base + merge midpoint.
* GizmoWallUnjoinSingle link-toggle pool (one icon per IFC path
  connection, previously sitting exactly on the connection point).
* GizmoWallFilletReedit pen icon at fillet corner.
* GizmoWallFilletToggleOpenings.

The clearance is a pure visual offset — bound operators still read
the world-space anchor (cursor / endpoint / connection point) at
execute time, so the action's target is unaffected.

Also tighten GizmoWallUnjoinSingle: gate poll on ``props.is_editing``
so the link-toggle icons only surface during the wall edit lifecycle
(matching every other edit-row icon), and downsize them via a new
``ICON_SCALE = 0.35`` constant since 16 of them at default scale
cluttered the viewport on path-heavy walls.

ruff + black clean. Wall gizmos test lane 14/14 pass.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-05 16:02:47 +02:00
parent 8faf9ff43d
commit f584a50fbb
2 changed files with 46 additions and 8 deletions
@@ -1702,6 +1702,33 @@ def get_screen_up(billboard_rot: Matrix) -> Vector:
return billboard_rot @ Vector((0.0, 1.0, 0.0))
# Screen-up distance lifted off floor-plane gizmo anchors in plan view. Matches
# the inter-icon stack spacing used by wall-corner stacks so single icons and
# stack bases sit at consistent screen-up positions when multiple groups render
# around the same wall endpoint.
DEFAULT_TOP_DOWN_CLEARANCE = 0.4
def top_down_clearance(
context: bpy.types.Context,
billboard_rot: Matrix,
distance: float = DEFAULT_TOP_DOWN_CLEARANCE,
) -> Vector:
"""Screen-up offset that keeps a floor-plane gizmo anchor visible in plan view.
In a top-down view the world-Z axis projects to ~zero on screen, so any
icon anchored on the floor (wall endpoints, corners, connection points,
the projected 3D cursor) sits directly on the click target it represents.
Adding this offset before ``billboarded_at`` shifts the icon along the
camera's up axis without changing the operator's world-space target.
Returns a zero vector outside the top-down cone so callers can apply it
unconditionally."""
if not tool.Blender.is_view_top_down(context):
return Vector((0.0, 0.0, 0.0))
return get_screen_up(billboard_rot) * distance
# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the
# element origin.
EXTEND_FLIP_EPSILON = 1e-4
+19 -8
View File
@@ -2175,11 +2175,14 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
if top_down:
# Swap world-Z stacking for screen-up stacking so each icon stays
# individually clickable when the camera projects world Z to zero.
# The shared ``top_down_clearance`` lifts the whole stack off the
# cursor so its small crosshair stays visible for precise pointing.
screen_up = tool.Blender.get_screen_up_world(context)
base_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
clearance = gizmo.top_down_clearance(context, billboard_rot)
for index, (gz, _local_z) in enumerate(resolved):
gz.hide = self.is_gizmo_hidden_by_modal(gz)
world_pos = base_world + screen_up * (index * self.CURSOR_STACK_OFFSET)
world_pos = base_world + clearance + screen_up * (index * self.CURSOR_STACK_OFFSET)
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
return
@@ -3358,7 +3361,9 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
# the active object's elevation — the height the wall is about to reach.
world_pos = mw @ Vector((0.0, icon_y, 0.0))
world_pos.z = active.matrix_world.translation.z
self.extend_vertical_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
billboard_rot = gizmo.get_billboard_rotation(context)
world_pos += gizmo.top_down_clearance(context, billboard_rot)
self.extend_vertical_icon.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
@@ -3463,12 +3468,13 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b)
billboard_rot = gizmo.get_billboard_rotation(context)
screen_up = gizmo.get_screen_up(billboard_rot)
clearance = gizmo.top_down_clearance(context, billboard_rot)
anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b)
# State 1: walls are already joined → Unjoin (bottom) + Fillet (above).
if _are_walls_joined(elem_a, elem_b):
corner = _collinear_boundary_world(seg_a, seg_b)
anchor = Vector((corner.x, corner.y, anchor_z))
anchor = Vector((corner.x, corner.y, anchor_z)) + clearance
self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon))
self.merge_icon.hide = True
self.join_icon.hide = True
@@ -3479,7 +3485,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
# at the boundary midpoint between them. No stack; single icon at the
# geometric boundary makes the merge target unambiguous.
if _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE):
boundary = _collinear_boundary_world(seg_a, seg_b)
boundary = _collinear_boundary_world(seg_a, seg_b) + clearance
self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot)
self.merge_icon.hide = False
self.unjoin_icon.hide = True
@@ -3503,7 +3509,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
self._hide_all()
return
intersection = Vector(intersection_tuple)
anchor = Vector((intersection.x, intersection.y, anchor_z))
anchor = Vector((intersection.x, intersection.y, anchor_z)) + clearance
self._stack_at(anchor, screen_up, billboard_rot, (self.extend_to_wall_icon, self.join_icon, self.fillet_icon))
self.unjoin_icon.hide = True
self.merge_icon.hide = True
@@ -3597,6 +3603,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
# GizmoGroup to allocate gizmos inside setup() — draw_prepare / refresh-time
# creation is forbidden — so the pool must be sized upfront for the worst case.
POOL_SIZE = 16
ICON_SCALE = 0.35
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
@@ -3611,6 +3618,9 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
element = tool.Ifc.get_entity(active)
if not element or not tool.Parametric.is_path_connectable_wall(element):
return False
props = tool.Model.get_wall_props(active)
if not props.is_editing:
return False
return True
def setup(self, context: bpy.types.Context) -> None:
@@ -3647,6 +3657,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
return
seg_self = _wall_axis_world_segment_from_geom(wall_obj, geom)
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = _iter_path_connections(elem)
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
@@ -3668,7 +3679,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom)
location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct)
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot)
icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
# Only the partner-GlobalId property is rewritten per frame; the operator
# binding itself is the long-lived handle set up at setup() time. GlobalId
@@ -4002,7 +4013,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
billboard_rot = gizmo.get_billboard_rotation(context)
origin = corner_obj.matrix_world.translation
top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT
anchor = Vector((origin.x, origin.y, top_z))
anchor = Vector((origin.x, origin.y, top_z)) + gizmo.top_down_clearance(context, billboard_rot)
self.edit_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot)
self.edit_icon.hide = False
@@ -4064,7 +4075,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
billboard_rot = gizmo.get_billboard_rotation(context)
origin = corner_obj.matrix_world.translation
top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT
anchor = Vector((origin.x, origin.y, top_z))
anchor = Vector((origin.x, origin.y, top_z)) + gizmo.top_down_clearance(context, billboard_rot)
offset_x = billboard_rot @ Vector((self.ICON_OFFSET_X, 0.0, 0.0))
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot)
self.toggle_openings_icon.hide = False