mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-23 17:36:29 +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.GizmoWallExtendVertically,
|
||||||
wall.GizmoWallFilletPreview,
|
wall.GizmoWallFilletPreview,
|
||||||
wall.GizmoWallFilletReedit,
|
wall.GizmoWallFilletReedit,
|
||||||
|
wall.GizmoWallFilletToggleOpenings,
|
||||||
wall.GizmoWallJoinIntersection,
|
wall.GizmoWallJoinIntersection,
|
||||||
wall.GizmoWallLinkToggle,
|
wall.GizmoWallLinkToggle,
|
||||||
wall.GizmoWallUnjoinSingle,
|
wall.GizmoWallUnjoinSingle,
|
||||||
|
|||||||
@@ -3273,12 +3273,12 @@ class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
|
|||||||
if active is None or active not in selected:
|
if active is None or active not in selected:
|
||||||
return False
|
return False
|
||||||
element = tool.Ifc.get_entity(active)
|
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
|
return False
|
||||||
other = next(o for o in selected if o is not active)
|
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.
|
# If the other object is also a wall, the wall-join gizmo handles it instead.
|
||||||
other_element = tool.Ifc.get_entity(other)
|
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 False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -3685,7 +3685,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
|||||||
icon.partner_obj = other_obj
|
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 +
|
"""Gizmo group for the wall-fillet preview: radius dimension widget +
|
||||||
trim-length dimension widget + validate / cancel icons.
|
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_get_cb = preview_base.make_dim_getter(_props_callback, "radius")
|
||||||
gz.move_set_cb = preview_base.make_dim_setter(_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
|
# 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
|
# The preview spans world space independent of either wall's local
|
||||||
# frame, so the active-object transform that `local_axis` would go
|
# frame, so the active-object transform that `local_axis` would go
|
||||||
# through is the wrong frame.
|
# through is the wrong frame.
|
||||||
@@ -3756,10 +3756,9 @@ class GizmoWallFilletPreview(bpy.types.GizmoGroup):
|
|||||||
self.radius_dim = gz
|
self.radius_dim = gz
|
||||||
|
|
||||||
# Sweep angle is geometrically invariant during drag (depends only on
|
# Sweep angle is geometrically invariant during drag (depends only on
|
||||||
# the angle between the two walls). Cached here per-frame from
|
# the angle between the two walls). Cached per frame so the trim
|
||||||
# `_position_gizmos` so the trim getter / setter can convert
|
# getter / setter can convert trim_length ↔ radius via tan(sweep/2)
|
||||||
# trim_length ↔ radius via `tan(sweep/2)` without re-running the full
|
# without re-running the full geometry pipeline on every drag tick.
|
||||||
# geometry pipeline on every drag tick.
|
|
||||||
self._sweep_angle = math.pi / 2
|
self._sweep_angle = math.pi / 2
|
||||||
|
|
||||||
# Trim-length widget expresses the SAME single DOF as the radius
|
# Trim-length widget expresses the SAME single DOF as the radius
|
||||||
@@ -3839,13 +3838,7 @@ class GizmoWallFilletPreview(bpy.types.GizmoGroup):
|
|||||||
|
|
||||||
return _set
|
return _set
|
||||||
|
|
||||||
def refresh(self, context: bpy.types.Context) -> None:
|
def position_gizmos(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:
|
|
||||||
wall_a_obj, wall_b_obj = _wall_fillet_preview_walls(context)
|
wall_a_obj, wall_b_obj = _wall_fillet_preview_walls(context)
|
||||||
if wall_a_obj is None or wall_b_obj is None:
|
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):
|
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
|
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):
|
class JoinWallsIntersection(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
bl_idname = "bim.join_walls_intersection"
|
bl_idname = "bim.join_walls_intersection"
|
||||||
bl_label = "Join Walls at Corner"
|
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
|
poll. Strict ``is_wall`` rejects fillet-corner walls (which have no
|
||||||
LAYER2 usage by IFC spec), so a regression to ``is_wall`` would silently
|
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
|
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
|
from bonsai.bim.module.model.wall import _iter_path_connections
|
||||||
|
|
||||||
source = inspect.getsource(_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 "
|
"trigger for this feature; replacing it with an ad-hoc draw call would "
|
||||||
"drift from the array-children bbox styling."
|
"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