From ed7b2fc233370253959580e26f7783d628662b62 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 2 Jun 2026 15:35:31 +0200 Subject: [PATCH] Stack wall-join trio along screen-up + L/T glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GizmoWallJoinIntersection used to place its icons at state-specific world points: join at floor Z, extend-to-wall at the active wall's top Z, fillet stacked screen-up above join. Same XY at different Z collapses to a single screen pixel in plan / top view, so two icons became one hit target — invisible from above. * position_gizmos now always-stacks along screen-up at a wall-top anchor in both the joined (unjoin + fillet) and the intersecting (extend + join + fillet) states. Order bottom-up is extend / L / fillet. Collinear-merge keeps its single boundary icon (no stack needed). * New _stack_anchor_z picks the active wall's top Z (or the taller of the two on mid-selection-transition frames). New _stack_at lays a tuple of icons along screen-up at the resolved anchor. * Glyph swap: join_icon -> VIEW3D_GT_wall_corner (L), extend_to_wall_icon -> VIEW3D_GT_wall_tee (T). Both classes already existed in bim/module/drawing/gizmos.py from an earlier commit; only the setup() bl_idname strings changed. The previous arrow-merge / arrow-extend pair read as the same direction once stacked. Forward-compat AST contracts in test_wall_gizmos_forward_compat.py pin the new invariants: the L and T bl_idnames must appear in setup(), and position_gizmos must route through _stack_at so a regression that reintroduces a direct billboarded_at write for any state-specific icon fails CI before it flattens the stack again. Also folds in a one-line typo fix in core/spatial.py: assign_container's per-element can_contain check iterated `e` but predicate-tested `root_element` (the outer for-loop variable), so every element in the comprehension was tested against the same container/element pair. Switch the argument to `e`. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 101 +++++++++--------- src/bonsai/bonsai/core/spatial.py | 2 +- .../model/test_wall_gizmos_forward_compat.py | 51 +++++++++ 3 files changed, 105 insertions(+), 49 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 242a090da7..e8b6ce0426 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -3450,11 +3450,14 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin default_color, highlight_color = self.get_decoration_colors() self.unjoin_icon = self.setup_icon_gizmo("VIEW3D_GT_split", default_color, highlight_color, "bim.unjoin_walls") self.merge_icon = self.setup_icon_gizmo("VIEW3D_GT_merge", default_color, highlight_color, "bim.merge_wall") + # L-corner glyph reads as "join at the corner"; differentiated from + # the T glyph (extend) by where the bars meet (corner vs midline). self.join_icon = self.setup_icon_gizmo( - "VIEW3D_GT_merge", default_color, highlight_color, "bim.join_walls_intersection" + "VIEW3D_GT_wall_corner", default_color, highlight_color, "bim.join_walls_intersection" ) + # T-junction glyph reads as "extend this wall into the other's side". self.extend_to_wall_icon = self.setup_icon_gizmo( - "VIEW3D_GT_extend", default_color, highlight_color, "bim.extend_walls_to_wall" + "VIEW3D_GT_wall_tee", default_color, highlight_color, "bim.extend_walls_to_wall" ) # Fillet entry — shows in the same two states (joined / intersect) # where rounding the corner is well-defined. Click enters the preview @@ -3485,27 +3488,22 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a) 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) + anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b) - # State 1: walls are already joined → show Unjoin only, at the shared - # corner's floor Z (no visibility lift — user expects the icon to sit - # exactly at the corner, not floating above it). + # 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) - self.unjoin_icon.matrix_basis = gizmo.billboarded_at(corner, billboard_rot) - self.unjoin_icon.hide = False + anchor = Vector((corner.x, corner.y, anchor_z)) + self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon)) self.merge_icon.hide = True self.join_icon.hide = True self.extend_to_wall_icon.hide = True - # Fillet entry stacked above the unjoin icon in screen-up. - screen_up = gizmo.get_screen_up(billboard_rot) - self.fillet_icon.matrix_basis = gizmo.billboarded_at( - corner + screen_up * self.ICON_STACK_OFFSET_Y, billboard_rot - ) - self.fillet_icon.hide = False return # State 2: walls are collinear (parallel axes on the same line) → show Merge - # at the boundary midpoint between them, at floor Z (no visibility lift). + # 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) self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot) @@ -3516,13 +3514,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self.fillet_icon.hide = True return - # State 3: non-parallel walls → show Join at the floor + Extend-to-Wall - # at the active wall's top. PARALLEL_DOT_THRESHOLD (cos 2°) is the only - # bound that matters: walls within 2° of parallel produce extrusion - # joints that race toward infinity, so project_axis_intersection - # returns None and hits the early-return below. Beyond that, any - # crossing is geometrically valid — distance from the nearest endpoint - # is the user's concern, not ours. + # State 3: non-parallel walls → Join (L, bottom) + Extend-to-Wall (T) + # + Fillet (top) stacked along screen-up at the wall-top anchor. + # PARALLEL_DOT_THRESHOLD (cos 2°) is the only bound that matters: + # walls within 2° of parallel produce extrusion joints that race + # toward infinity, so project_axis_intersection returns None and the + # early-return below hides the whole group. intersection_tuple = core.project_axis_intersection( (tuple(seg_a[0]), tuple(seg_a[1])), (tuple(seg_b[0]), tuple(seg_b[1])), @@ -3532,36 +3529,44 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self._hide_all() return intersection = Vector(intersection_tuple) - - # Join sits on the floor (lowest endpoint Z across both wall axes), exactly - # where the corner meets the ground — no visibility lift. - floor_z = min(seg_a[0].z, seg_a[1].z, seg_b[0].z, seg_b[1].z) - join_world = Vector((intersection.x, intersection.y, floor_z)) - self.join_icon.matrix_basis = gizmo.billboarded_at(join_world, billboard_rot) - self.join_icon.hide = False - - # Extend-to-Wall sits at the active wall's top, same XY as the join icon — - # the Z gap is what differentiates "join at corner" from "extend into other". - active = context.active_object if context.active_object in selected else None - geom = tool.Wall.read_geometry(active) if active else None - if geom is None: - self.extend_to_wall_icon.hide = True - else: - active_top_z = active.matrix_world.translation.z + geom["height"] - extend_world = Vector((intersection.x, intersection.y, active_top_z)) - self.extend_to_wall_icon.matrix_basis = gizmo.billboarded_at(extend_world, billboard_rot) - self.extend_to_wall_icon.hide = False - - # Fillet entry stacked above the join icon in screen-up. - screen_up = gizmo.get_screen_up(billboard_rot) - self.fillet_icon.matrix_basis = gizmo.billboarded_at( - join_world + screen_up * self.ICON_STACK_OFFSET_Y, billboard_rot - ) - self.fillet_icon.hide = False - + anchor = Vector((intersection.x, intersection.y, anchor_z)) + 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 + def _stack_anchor_z( + self, + context: bpy.types.Context, + selected: list[bpy.types.Object], + geom_a: dict, + geom_b: dict, + ) -> float: + # Wall-top Z is the bottom of the screen-up stack — high enough that + # the icons sit on top of the wall instead of clipping into it. + # Prefer the active wall's top (the height the user is operating on); + # fall back to the taller of the two if the active object isn't one + # of the selected walls (mid-selection-transition frame). + active = context.active_object if context.active_object in selected else None + if active is selected[0]: + return active.matrix_world.translation.z + geom_a["height"] + if active is selected[1]: + return active.matrix_world.translation.z + geom_b["height"] + return max( + selected[0].matrix_world.translation.z + geom_a["height"], + selected[1].matrix_world.translation.z + geom_b["height"], + ) + + def _stack_at( + self, + anchor: Vector, + screen_up: Vector, + billboard_rot: Matrix, + icons: tuple[bpy.types.Gizmo, ...], + ) -> None: + for k, icon in enumerate(icons): + icon.matrix_basis = gizmo.billboarded_at(anchor + screen_up * (self.ICON_STACK_OFFSET_Y * k), billboard_rot) + icon.hide = False + class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo): """Link-toggle glyph with a partner-wall highlight on hover. The owning diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 3af14821a2..98db273f46 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -64,7 +64,7 @@ def assign_container( spatial.disable_editing(obj) all_elements.add(root_element) all_elements.update(spatial.get_decomposition(root_element)) - if products := [e for e in root_elements if spatial.can_contain(container, root_element)]: + if products := [e for e in root_elements if spatial.can_contain(container, e)]: ifc.run("spatial.assign_container", products=products, relating_structure=container) for element in all_elements: if obj := ifc.get_object(element): 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 fd3c4c44cf..e10ac171f5 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 @@ -159,3 +159,54 @@ def test_gizmo_wall_add_opening_accepts_fillet_corner_active(): "drops fillet-corner walls. Use is_path_connectable_wall instead, matching " "the host gate every other wall-state gizmo group uses." ) + + +def test_join_intersection_uses_l_and_t_glyphs(): + """``GizmoWallJoinIntersection.setup`` must bind the join icon to the L + glyph (``VIEW3D_GT_wall_corner``) and the extend-to icon to the T glyph + (``VIEW3D_GT_wall_tee``). The L / T pair makes the corner-join vs + extend-into-side distinction read at a glance — a regression to the + arrow-merge glyph for both icons makes them visually indistinguishable + once they're stacked at the same XY.""" + from bonsai.bim.module.model.wall import GizmoWallJoinIntersection + + source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.setup)) + assert '"VIEW3D_GT_wall_corner"' in source, ( + "GizmoWallJoinIntersection.setup must bind join_icon to VIEW3D_GT_wall_corner " + "(the L glyph). The arrow-merge glyph (VIEW3D_GT_merge) is the collinear-merge " + "case and was visually ambiguous with the extend-to icon when both were stacked." + ) + assert '"VIEW3D_GT_wall_tee"' in source, ( + "GizmoWallJoinIntersection.setup must bind extend_to_wall_icon to " + "VIEW3D_GT_wall_tee (the T glyph). The arrow-extend glyph was visually " + "ambiguous with the join icon when both were stacked." + ) + + +def test_join_intersection_stacks_along_screen_up_in_both_states(): + """``GizmoWallJoinIntersection.position_gizmos`` must route both the + joined (unjoin + fillet) and the intersecting (join + extend + fillet) + states through ``_stack_at`` so the icons stay individually clickable + in any view, including top / plan view where world-Z separation + collapses to zero on screen. A regression that re-introduces a + per-state ``billboarded_at(corner, ...)`` write outside ``_stack_at`` + silently flattens the stack back onto one screen pixel.""" + from bonsai.bim.module.model.wall import GizmoWallJoinIntersection + + source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.position_gizmos)) + tree = ast.parse(source) + 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 "_stack_at" in call_names, ( + "GizmoWallJoinIntersection.position_gizmos must call self._stack_at to " + "lay icons along screen-up at the wall-top anchor. Direct " + "billboarded_at writes for the join/unjoin/extend/fillet icons bypass " + "the stacking contract and re-introduce the top-view collapse bug." + )