From f584a50fbb213cde406a98ae0ba125f4d214e20a Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 5 Jun 2026 16:02:47 +0200 Subject: [PATCH] Clear wall-edit gizmos off click targets in plan view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../bonsai/bim/module/drawing/gizmos.py | 27 +++++++++++++++++++ src/bonsai/bonsai/bim/module/model/wall.py | 27 +++++++++++++------ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 638e0f6990..4c6717bac7 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 7c60102ca7..ecd50defb7 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -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