Fix fillet preview crash + surface openings on fillet walls

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.
This commit is contained in:
Gorgious56
2026-06-02 11:39:12 +02:00
parent 18dc7abb06
commit ab9152e32d
3 changed files with 142 additions and 16 deletions
@@ -104,6 +104,7 @@ classes = (
wall.GizmoWallExtendVertically,
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
+71 -15
View File
@@ -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"
@@ -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."
)