From ab9152e32d1e35e5c016f7088be646924134231b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 2 Jun 2026 11:39:12 +0200 Subject: [PATCH] Fix fillet preview crash + surface openings on fillet walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wall-gizmo fixes: * GizmoWallFilletPreview crashed on every draw_prepare after the DRY-colors refactor moved decoration lookups onto self.get_decoration_colors() — that method lives on BillboardingGizmoGroupMixin / BaseParametricGizmoGroup, but GizmoWallFilletPreview inherited only from bpy.types.GizmoGroup. setup() AttributeError'd silently, leaving radius_dim and friends unset. Add the mixin to the bases; rename _position_gizmos to position_gizmos so the mixin's refresh/draw_prepare dispatch lands correctly and drop the now-redundant overrides. * GizmoWallAddOpening's poll gated on the strict is_wall predicate, which rejects fillet-corner walls (no LAYER2 usage by IFC spec). Switch to is_path_connectable_wall on both the active and the partner-exclusion checks so the add-opening icon surfaces over curved corners — matching every other wall-state gizmo's host gate. * Show / hide openings was only available on LAYER2 walls because GizmoWallEdition's parametric edit pipeline (which carries the toggle) refuses fillet bodies. Add GizmoWallFilletToggleOpenings, a dedicated single-icon group that polls on is_fillet_corner_wall and reuses bim.toggle_wall_openings — the body stays untouched. Forward-compat AST guards in test_wall_gizmos_forward_compat.py pin both invariants: every wall GizmoGroup that calls self.get_decoration_colors() must inherit a mixin that provides it, and GizmoWallAddOpening.poll must keep using the looser predicate. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 1 + src/bonsai/bonsai/bim/module/model/wall.py | 86 +++++++++++++++---- .../model/test_wall_gizmos_forward_compat.py | 71 ++++++++++++++- 3 files changed, 142 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 7ec2346bea..bf433b5f46 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -104,6 +104,7 @@ classes = ( wall.GizmoWallExtendVertically, wall.GizmoWallFilletPreview, wall.GizmoWallFilletReedit, + wall.GizmoWallFilletToggleOpenings, wall.GizmoWallJoinIntersection, wall.GizmoWallLinkToggle, wall.GizmoWallUnjoinSingle, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index bf54646829..bfa056605b 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -3273,12 +3273,12 @@ class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin if active is None or active not in selected: return False element = tool.Ifc.get_entity(active) - if not element or not tool.Blender.Modifier.is_wall(element): + if not element or not tool.Parametric.is_path_connectable_wall(element): return False other = next(o for o in selected if o is not active) # If the other object is also a wall, the wall-join gizmo handles it instead. other_element = tool.Ifc.get_entity(other) - if other_element and tool.Blender.Modifier.is_wall(other_element): + if other_element and tool.Parametric.is_path_connectable_wall(other_element): return False return True @@ -3685,7 +3685,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix icon.partner_obj = other_obj -class GizmoWallFilletPreview(bpy.types.GizmoGroup): +class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): """Gizmo group for the wall-fillet preview: radius dimension widget + trim-length dimension widget + validate / cancel icons. @@ -3731,7 +3731,7 @@ class GizmoWallFilletPreview(bpy.types.GizmoGroup): gz.move_get_cb = preview_base.make_dim_getter(_props_callback, "radius") gz.move_set_cb = preview_base.make_dim_setter(_props_callback, "radius") # Set `axis` only (NOT `local_axis`) so `get_axis_direction` falls - # through to the world-space direction we set in `_position_gizmos`. + # through to the world-space direction set per frame on each gizmo. # The preview spans world space independent of either wall's local # frame, so the active-object transform that `local_axis` would go # through is the wrong frame. @@ -3756,10 +3756,9 @@ class GizmoWallFilletPreview(bpy.types.GizmoGroup): self.radius_dim = gz # Sweep angle is geometrically invariant during drag (depends only on - # the angle between the two walls). Cached here per-frame from - # `_position_gizmos` so the trim getter / setter can convert - # trim_length ↔ radius via `tan(sweep/2)` without re-running the full - # geometry pipeline on every drag tick. + # the angle between the two walls). Cached per frame so the trim + # getter / setter can convert trim_length ↔ radius via tan(sweep/2) + # without re-running the full geometry pipeline on every drag tick. self._sweep_angle = math.pi / 2 # Trim-length widget expresses the SAME single DOF as the radius @@ -3839,13 +3838,7 @@ class GizmoWallFilletPreview(bpy.types.GizmoGroup): return _set - def refresh(self, context: bpy.types.Context) -> None: - self._position_gizmos(context) - - def draw_prepare(self, context: bpy.types.Context) -> None: - self._position_gizmos(context) - - def _position_gizmos(self, context: bpy.types.Context) -> None: + def position_gizmos(self, context: bpy.types.Context) -> None: wall_a_obj, wall_b_obj = _wall_fillet_preview_walls(context) if wall_a_obj is None or wall_b_obj is None: for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon): @@ -4016,6 +4009,69 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix self.edit_icon.hide = False +class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Surfaces the show / hide openings icon on a fillet-corner wall. + + GizmoWallEdition's idle row already exposes this toggle for LAYER2 walls, + but its poll routes through the parametric edit pipeline which by IFC + spec rejects fillet corners (their banana body is hand-built and would be + flattened by the parametric regen). The openings toggle itself is a + viewport-state action independent of the body, so a parallel poll keeps + it available without re-opening the parametric edits.""" + + bl_idname = "OBJECT_GGT_bim_wall_fillet_toggle_openings" + bl_label = "Fillet Wall Toggle Openings Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_TOP_LIFT: ClassVar[float] = 0.15 + # Screen-space X offset from the pen icon so the two stack horizontally + # rather than overlap at the chord midpoint. + ICON_OFFSET_X: ClassVar[float] = 0.4 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not _wall_gizmo_poll_gate(context): + return False + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + if len(list(tool.Blender.get_selected_objects())) != 1: + return False + element = tool.Ifc.get_entity(active) + if element is None or not element.is_a("IfcWall"): + return False + return tool.Parametric.is_fillet_corner_wall(element) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.toggle_openings_icon = self.setup_icon_gizmo( + "VIEW3D_GT_add_opening", + default_color, + highlight_color, + "bim.toggle_wall_openings", + ) + + def position_gizmos(self, context: bpy.types.Context) -> None: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + self.toggle_openings_icon.hide = True + return + corner_obj = selected[0] + geom = _get_wall_geom_cached(self, corner_obj) + if geom is None: + self.toggle_openings_icon.hide = True + return + 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)) + 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 + + class JoinWallsIntersection(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.join_walls_intersection" bl_label = "Join Walls at Corner" 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 6a1067bcab..fd3c4c44cf 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 @@ -41,7 +41,7 @@ def test_iter_path_connections_uses_path_connectable_predicate(): poll. Strict ``is_wall`` rejects fillet-corner walls (which have no LAYER2 usage by IFC spec), so a regression to ``is_wall`` would silently drop fillet partners from the connection list — visible to the user as - "the corner looks unconnected from the adjacent wall's selection.\"""" + "the corner looks unconnected from the adjacent wall's selection.\" """ from bonsai.bim.module.model.wall import _iter_path_connections source = inspect.getsource(_iter_path_connections) @@ -90,3 +90,72 @@ def test_gizmo_wall_link_toggle_invokes_partner_bbox_helper(): "trigger for this feature; replacing it with an ad-hoc draw call would " "drift from the array-children bbox styling." ) + + +def test_every_wall_gizmo_group_resolves_get_decoration_colors(): + """Any wall ``GizmoGroup`` whose ``setup()`` reads decoration colours via + ``self.get_decoration_colors()`` must inherit from a mixin that supplies + it (``gizmo.BaseParametricGizmoGroup`` or ``gizmo.BillboardingGizmoGroupMixin``). + Without the mixin the call AttributeErrors inside ``setup()``, Blender + logs the failure and skips the rest of ``setup()``, and every later + ``draw_prepare()`` blows up on whichever attribute the truncated setup + failed to assign — a silent, runtime-only regression that no other test + catches.""" + import bpy + + from bonsai.bim.module.model import wall as wall_module + + offenders: list[str] = [] + for name in dir(wall_module): + cls = getattr(wall_module, name) + if not inspect.isclass(cls): + continue + if inspect.getmodule(cls) is not wall_module: + continue + if not issubclass(cls, bpy.types.GizmoGroup): + continue + setup = cls.__dict__.get("setup") + if setup is None: + continue + try: + src = inspect.getsource(setup) + except (OSError, TypeError): + continue + if "self.get_decoration_colors()" not in src: + continue + if not hasattr(cls, "get_decoration_colors"): + offenders.append(cls.__name__) + + assert not offenders, ( + f"GizmoGroup subclasses {offenders} call self.get_decoration_colors() in " + "setup() but inherit from no class that provides it. Add " + "gizmo.BillboardingGizmoGroupMixin (or gizmo.BaseParametricGizmoGroup) to " + "the class bases — both define get_decoration_colors and are the canonical " + "wall-gizmo mixins." + ) + + +def test_gizmo_wall_add_opening_accepts_fillet_corner_active(): + """``GizmoWallAddOpening.poll`` must gate on ``is_path_connectable_wall``, + not the strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 + usage by IFC spec, so the strict predicate rejects them and the + add-opening icon never surfaces over a curved corner — symmetry with the + join / unjoin / extend wall gizmos (all of which already poll on the + looser predicate) is required for the user to drop openings into fillet + corners at all.""" + from bonsai.bim.module.model.wall import GizmoWallAddOpening + + source = textwrap.dedent(inspect.getsource(GizmoWallAddOpening.poll)) + tree = ast.parse(source) + attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)} + + assert "is_path_connectable_wall" in attr_names, ( + "GizmoWallAddOpening.poll must gate on tool.Parametric.is_path_connectable_wall " + "for both the active element and the partner-exclusion check. The strict " + "is_wall predicate hides the add-opening gizmo over every fillet-corner wall." + ) + assert "is_wall" not in attr_names, ( + "GizmoWallAddOpening.poll must NOT call .is_wall — that strict predicate " + "drops fillet-corner walls. Use is_path_connectable_wall instead, matching " + "the host gate every other wall-state gizmo group uses." + )