mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -104,6 +104,7 @@ classes = (
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user