diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py
index bf433b5f46..3ed53de817 100644
--- a/src/bonsai/bonsai/bim/module/model/__init__.py
+++ b/src/bonsai/bonsai/bim/module/model/__init__.py
@@ -248,11 +248,13 @@ classes = (
roof.AddRoof,
roof.CancelEditingRoof,
roof.CopyRoofParameters,
+ roof.CycleRoofGenerationMethod,
roof.FinishEditingRoof,
roof.EnableEditingRoof,
roof.CancelEditingRoofPath,
roof.FinishEditingRoofPath,
roof.EnableEditingRoofPath,
+ roof.GizmoRoofEdition,
roof.RemoveRoof,
roof.SetGableRoofEdgeAngle,
mep.MEPAddObstruction,
diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py
index b949827ed2..ec3d821248 100644
--- a/src/bonsai/bonsai/bim/module/model/roof.py
+++ b/src/bonsai/bonsai/bim/module/model/roof.py
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see .
import json
-from math import cos, pi, radians, tan
+from math import atan2, cos, degrees, pi, radians, tan
from typing import Any, Literal, Union
import bmesh
@@ -32,9 +32,11 @@ from mathutils import Quaternion, Vector
import bonsai.core.root
import bonsai.tool as tool
+from bonsai.bim.module.drawing import gizmos as gizmo
+from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
-from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
+from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -211,7 +213,13 @@ def generate_hipped_roof_bmesh(
new_verts = [bm.verts.new(v) for v in verts]
new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges]
- new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
+ # Skip degenerate faces. ``bpypolyskel.polygonize`` can emit a face whose
+ # vertex list contains the same index twice on certain footprint /
+ # slope combinations (the straight-skeleton collapses two ridge events
+ # onto the same vertex). ``bm.faces.new`` rejects those with
+ # ``found the same (BMVert) used multiple times``; dropping them keeps
+ # the rest of the roof intact instead of aborting the whole rebuild.
+ new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces if len(set(face)) == len(face)]
if mode == "HEIGHT": # Calculate the angle we ended up with.
new_faces[0].normal_update()
@@ -397,6 +405,11 @@ def generate_hipped_roof_bmesh(
if is_internal:
faces_to_delete.add(face)
bmesh.ops.delete(bm, geom=list(faces_to_delete), context="FACES")
+ # Final pass: ``remove_doubles`` + internal-face deletion above can leave
+ # the bottom slab faces flipped at low slopes, where the kernel's
+ # "outward" inference becomes ambiguous on near-flat geometry. Recompute
+ # once more on the final topology so the eave plane points down.
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
return bm
@@ -636,32 +649,111 @@ class _RoofEditMixin(PathPreservingEditMixin):
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
+ @classmethod
+ def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ """Rebuild the roof bmesh from the just-restored draft props so the
+ viewport reverts to the pre-edit geometry. Same helper the modal
+ edits use, just driven by the cancelled props instead of in-flight
+ drag values."""
+ update_roof_modifier_bmesh(obj)
-class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.enable_editing_roof"
- bl_label = "Enable Editing Roof"
+
+EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
+ "roof",
+ _RoofEditMixin,
+ labels=(
+ ("Enable Editing Roof", ""),
+ ("Finish Editing Roof", ""),
+ ("Cancel Editing Roof", ""),
+ ),
+ module_name=__name__,
+)
+
+
+# Fixed horizontal run for the slope gizmo: the draggable value is the
+# vertical rise at this distance from the anchor, in the rise/run convention.
+_ROOF_SLOPE_REFERENCE_RUN = 1.0
+# One degree shy of vertical; avoids tan() blow-up when the user drags the
+# rise handle past the gizmo's anchor.
+_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
+
+
+class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
+ """Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
+
+ bl_idname = "bim.cycle_roof_generation_method"
+ bl_label = "Cycle Roof Generation Method"
bl_options = {"REGISTER", "UNDO"}
- def _execute(self, context):
- return self._enable_targets(context)
+ element_checker = tool.Parametric.is_roof
+ props_getter = tool.Model.get_roof_props
+ type_literal = tool.Model.RoofGenerationMethod
+ type_attr = "generation_method"
+
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ return self._cycle_type(context)
-class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.cancel_editing_roof"
- bl_label = "Cancel Editing Roof"
- bl_options = {"REGISTER", "UNDO"}
+class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
+ bl_idname = "OBJECT_GGT_bim_roof_edition"
+ bl_label = "Roof Editing Gizmo"
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "WINDOW"
+ bl_options = {"3D", "PERSISTENT"}
- def _execute(self, context):
- return self._cancel_targets(context)
+ enable_editing_operator = "bim.enable_editing_roof"
+ finish_editing_operator = "bim.finish_editing_roof"
+ cancel_editing_operator = "bim.cancel_editing_roof"
+ cycle_type_operator = "bim.cycle_roof_generation_method"
+ # Positions for all three dimensions are set per-frame by the position
+ # override below; no static ``matrix_position`` is needed.
+ dimension_gizmo_props = [
+ DimensionGizmoConfig(
+ attr_name="height",
+ axis=(0, 0, 1),
+ min_value=0.01,
+ visibility_condition=lambda p: p.generation_method == "HEIGHT",
+ ),
+ DimensionGizmoConfig(
+ attr_name="angle",
+ axis=(0, 0, 1),
+ prop_name="Slope",
+ min_value=0.0,
+ visibility_condition=lambda p: p.generation_method == "ANGLE",
+ compute_value=lambda p: tan(p.angle) * _ROOF_SLOPE_REFERENCE_RUN,
+ apply_value=lambda p, rise: setattr(
+ p, "angle", min(_ROOF_MAX_SLOPE_ANGLE, max(0.0, atan2(rise, _ROOF_SLOPE_REFERENCE_RUN)))
+ ),
+ text_formatter=lambda p, rise: (f"{tool.Unit.format_distance(rise)} ({degrees(p.angle):.1f}°)"),
+ ),
+ DimensionGizmoConfig(
+ attr_name="roof_thickness",
+ axis=(0, 0, -1),
+ min_value=0.001,
+ # The line shows the perpendicular slab thickness (matching the
+ # pset value and the drag delta); the true vertical span is
+ # ``roof_thickness / cos(angle)``, longer than what is drawn.
+ ),
+ ]
-class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.finish_editing_roof"
- bl_label = "Finish Editing Roof"
- bl_options = {"REGISTER", "UNDO"}
+ props_getter = tool.Model.get_roof_props
+ gizmo_pref_name = "roof"
- def _execute(self, context):
- return self._finish_targets(context)
+ @classmethod
+ def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
+ return tool.Parametric.is_roof(element)
+
+ def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
+ """Anchor every dimension gizmo at the object origin. Each gizmo's
+ declared axis (height/slope along +Z, thickness along -Z) separates
+ them in 3D so they don't visually collide despite sharing a
+ position; the height + slope gizmos themselves are mutually
+ exclusive via ``visibility_condition`` on ``generation_method``."""
+ origin = Vector((0.0, 0.0, 0.0))
+ self.set_dimension_gizmo_position("height", mw, origin, (0, 0, 1))
+ self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
+ self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py
index 6d30192485..d738cc39c0 100644
--- a/src/bonsai/bonsai/tool/loader.py
+++ b/src/bonsai/bonsai/tool/loader.py
@@ -1216,7 +1216,7 @@ class Loader(bonsai.core.tool.Loader):
) -> bool:
items = [i["item"] for i in ifcopenshell.util.representation.resolve_items(representation)]
if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"):
- if tool.Blender.Modifier.is_railing(element):
+ if tool.Parametric.is_railing(element):
return False
return True
elif len(items) and ( # See #2508 why we accommodate for invalid IFCs here
@@ -1224,7 +1224,7 @@ class Loader(bonsai.core.tool.Loader):
and len({i.is_a() for i in items}) == 1
and len({i.Radius for i in items}) == 1
):
- if tool.Blender.Modifier.is_railing(element):
+ if tool.Parametric.is_railing(element):
return False
return True
return False
diff --git a/src/bonsai/test/bim/module/model/test_roof_gizmos.py b/src/bonsai/test/bim/module/model/test_roof_gizmos.py
new file mode 100644
index 0000000000..1d7e1c48a1
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_roof_gizmos.py
@@ -0,0 +1,306 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Unit tests for the roof parametric gizmo group.
+
+Covers the parts of ``GizmoRoofEdition`` that don't need a live Blender
+viewport: the mode-conditional ``visibility_condition`` lambdas, the
+slope ``compute_value`` / ``apply_value`` roundtrip, the
+``CycleRoofGenerationMethod`` operator metadata + cycle behaviour, and
+the ``_update_dimension_gizmo_positions`` override that anchors all three
+dimension gizmos at the object's local origin."""
+
+import math
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import bpy
+import pytest
+
+pytestmark = pytest.mark.model
+
+
+def _get_config(attr_name):
+ """Return the ``DimensionGizmoConfig`` for ``attr_name`` from the roof gizmo."""
+ from bonsai.bim.module.model.roof import GizmoRoofEdition
+
+ for cfg in GizmoRoofEdition.dimension_gizmo_props:
+ if cfg.attr_name == attr_name:
+ return cfg
+ raise AssertionError(f"no DimensionGizmoConfig with attr_name={attr_name!r}")
+
+
+# ----------------------------------------------------------------------------
+# Mode-conditional visibility
+# ----------------------------------------------------------------------------
+#
+# ``height`` and ``angle`` are mutually exclusive — exactly one is shown
+# depending on ``generation_method``. ``roof_thickness`` applies regardless
+# of the generation mode.
+
+
+def test_height_gizmo_visible_only_in_height_mode():
+ cfg = _get_config("height")
+ assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is True
+ assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is False
+
+
+def test_angle_gizmo_visible_only_in_angle_mode():
+ cfg = _get_config("angle")
+ assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is True
+ assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is False
+
+
+def test_thickness_has_no_mode_gate():
+ """Slab thickness applies to both generation modes — pinning
+ ``visibility_condition is None`` guards against an accidental mode-gate
+ being added later that would silently hide it when toggling modes."""
+ assert _get_config("roof_thickness").visibility_condition is None
+
+
+# ----------------------------------------------------------------------------
+# Slope (angle) ↔ rise roundtrip
+# ----------------------------------------------------------------------------
+#
+# The slope handle displays vertical rise at a fixed 1m run; dragging it
+# updates ``props.angle`` via ``atan2(rise, run)``. Roundtrip preservation
+# is the contract — feeding ``compute_value`` into ``apply_value`` must
+# leave the angle unchanged (within float tolerance).
+
+
+def test_slope_compute_value_returns_rise_at_reference_run():
+ from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
+
+ cfg = _get_config("angle")
+ # 30° slope → rise = tan(30°) * 1m ≈ 0.5774 m
+ props = SimpleNamespace(angle=math.radians(30))
+ assert cfg.compute_value(props) == pytest.approx(math.tan(math.radians(30)) * _ROOF_SLOPE_REFERENCE_RUN)
+
+
+def test_slope_apply_value_sets_angle_from_rise():
+ from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
+
+ cfg = _get_config("angle")
+ props = SimpleNamespace(angle=0.0)
+ cfg.apply_value(props, 0.5)
+ assert props.angle == pytest.approx(math.atan2(0.5, _ROOF_SLOPE_REFERENCE_RUN))
+
+
+def test_slope_roundtrip_preserves_angle():
+ cfg = _get_config("angle")
+ for deg in (5, 15, 30, 45, 60, 80):
+ props = SimpleNamespace(angle=math.radians(deg))
+ rise = cfg.compute_value(props)
+ cfg.apply_value(props, rise)
+ assert math.degrees(props.angle) == pytest.approx(deg, abs=1e-6)
+
+
+def test_slope_apply_value_clamps_negative_to_zero():
+ """A negative drag (rise < 0) must not produce a negative angle —
+ ``atan2(-x, run)`` would yield a negative result, but ``apply_value``
+ clamps to ``[0, pi/2 - 1e-3]`` so the roof never inverts."""
+ cfg = _get_config("angle")
+ props = SimpleNamespace(angle=math.radians(30))
+ cfg.apply_value(props, -1.0)
+ assert props.angle == 0.0
+
+
+def test_slope_apply_value_clamps_at_near_vertical():
+ """Slopes approaching 90° are clamped just below to avoid a vertical
+ extrusion that would degenerate the bisect step in
+ ``generate_hipped_roof_bmesh``."""
+ from bonsai.bim.module.model.roof import _ROOF_MAX_SLOPE_ANGLE
+
+ cfg = _get_config("angle")
+ props = SimpleNamespace(angle=0.0)
+ cfg.apply_value(props, 1e9) # absurdly steep
+ assert props.angle == pytest.approx(_ROOF_MAX_SLOPE_ANGLE)
+
+
+# ----------------------------------------------------------------------------
+# Cycle operator metadata
+# ----------------------------------------------------------------------------
+#
+# ``CycleRoofGenerationMethod`` plugs into ``CycleTypeMixin`` so the
+# HEIGHT ↔ ANGLE icon cycles through the two values. The mixin reads four
+# class attributes to do its work; if any drift, the cycle no-ops or
+# CANCELLED-loops in subtle ways. Pin them here.
+
+
+def test_cycle_operator_class_metadata():
+ from typing import get_args
+
+ from bonsai import tool
+ from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
+
+ assert CycleRoofGenerationMethod.bl_idname == "bim.cycle_roof_generation_method"
+ assert CycleRoofGenerationMethod.element_checker == tool.Parametric.is_roof
+ assert CycleRoofGenerationMethod.props_getter == tool.Model.get_roof_props
+ assert CycleRoofGenerationMethod.type_attr == "generation_method"
+ # The Literal resolves to ("HEIGHT", "ANGLE") — the mixin calls
+ # ``get_args(type_literal)`` to enumerate the cycle.
+ assert get_args(CycleRoofGenerationMethod.type_literal) == ("HEIGHT", "ANGLE")
+ assert CycleRoofGenerationMethod.type_literal is tool.Model.RoofGenerationMethod
+
+
+def test_cycle_operator_wired_on_gizmo_group():
+ """The gizmo group's ``cycle_type_operator`` must match the bl_idname or
+ the base class skips the cycle icon entirely (see gizmos.py:4987)."""
+ from bonsai.bim.module.model.roof import CycleRoofGenerationMethod, GizmoRoofEdition
+
+ assert GizmoRoofEdition.cycle_type_operator == CycleRoofGenerationMethod.bl_idname
+
+
+def _cycle_stub_self(*, reverse: bool, props, element_is_target: bool = True):
+ """Build a stub ``self`` for ``CycleTypeMixin._cycle_type``.
+
+ ``bpy.types.Operator`` subclasses can't be ``__init__``-ed outside of
+ Blender's registration path (``bpy_struct.__new__`` rejects a bare
+ call). Calling the unbound mixin method with a stub ``self`` that
+ mirrors the class attributes the method reads is the cleanest way to
+ exercise the cycle logic without launching a registered operator
+ instance.
+
+ ``element_checker`` and ``props_getter`` are captured by the cycle
+ operator at class-definition time, so global ``tool.*`` patches at
+ test time can't intercept them — the stub injects callables directly
+ instead. ``_resolve_target`` is bound from ``TypeAccessorBase`` so
+ the cycle method's call into it dispatches against the stub
+ attributes."""
+ from types import MethodType
+
+ from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
+ from bonsai.bim.parametric_lifecycle import TypeAccessorBase
+
+ stub = SimpleNamespace(
+ reverse=reverse,
+ skip_element_check=False,
+ element_checker=lambda _elem: element_is_target,
+ props_getter=lambda _obj: props,
+ type_literal=CycleRoofGenerationMethod.type_literal,
+ type_attr=CycleRoofGenerationMethod.type_attr,
+ )
+ stub._resolve_target = MethodType(TypeAccessorBase._resolve_target, stub)
+ return stub
+
+
+def test_cycle_type_advances_forward():
+ """``_cycle_type`` advances the prop value to the next item in the
+ Literal. The stub injects ``element_checker`` / ``props_getter``
+ directly so the method runs without a live IFC fixture."""
+ from bonsai import tool
+ from bonsai.bim import parametric_lifecycle as gizmo_module
+
+ props = SimpleNamespace(generation_method="HEIGHT")
+ context = SimpleNamespace(active_object=object())
+
+ with patch.object(tool.Ifc, "get_entity", return_value=object()):
+ result = gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=False, props=props), context)
+ assert result == {"FINISHED"}
+ assert props.generation_method == "ANGLE"
+
+
+def test_cycle_type_reverse_walks_backward():
+ """Shift+click sets ``reverse=True`` and walks the cycle in the other
+ direction — from HEIGHT that means wrapping to ANGLE (the last item)."""
+ from bonsai import tool
+ from bonsai.bim import parametric_lifecycle as gizmo_module
+
+ props = SimpleNamespace(generation_method="HEIGHT")
+ context = SimpleNamespace(active_object=object())
+
+ with patch.object(tool.Ifc, "get_entity", return_value=object()):
+ gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=True, props=props), context)
+ assert props.generation_method == "ANGLE" # wrapped from HEIGHT backward
+
+
+def test_cycle_type_cancels_when_active_is_not_a_roof():
+ """Non-roof active object → CANCELLED, props untouched. Guards against
+ a stray cycle click on a wall mutating ``wall.generation_method`` (a
+ non-existent attr) and silently no-oping or AttributeError-ing later."""
+ from bonsai import tool
+ from bonsai.bim import parametric_lifecycle as gizmo_module
+
+ props = SimpleNamespace(generation_method="HEIGHT")
+ context = SimpleNamespace(active_object=object())
+
+ with patch.object(tool.Ifc, "get_entity", return_value=object()):
+ result = gizmo_module.CycleTypeMixin._cycle_type(
+ _cycle_stub_self(reverse=False, props=props, element_is_target=False),
+ context,
+ )
+ assert result == {"CANCELLED"}
+ assert props.generation_method == "HEIGHT"
+
+
+# ----------------------------------------------------------------------------
+# _update_dimension_gizmo_positions — origin anchoring
+# ----------------------------------------------------------------------------
+#
+# All three dimension gizmos anchor at the object's local origin. Their
+# declared axes (height/slope +Z, thickness -Z) separate them in 3D so
+# they don't visually collide despite sharing a position; height + slope
+# are themselves mutually exclusive via visibility_condition on
+# generation_method.
+
+
+def test_override_positions_all_dimensions_at_object_origin():
+ """The override calls ``set_dimension_gizmo_position`` with the
+ object-local origin (0, 0, 0) for every dimension gizmo. Anchoring at
+ the object origin keeps the gizmos tied to the object's matrix_world
+ rather than to footprint geometry that may not be cached yet — fixes
+ the first-click default-identity-matrix bug structurally."""
+ from bonsai.bim.module.model.roof import GizmoRoofEdition
+
+ calls: dict[str, tuple] = {}
+
+ def record(attr_name, _mw, position, axis, _value=None):
+ calls[attr_name] = (position, axis)
+
+ stub = SimpleNamespace(set_dimension_gizmo_position=record)
+ GizmoRoofEdition._update_dimension_gizmo_positions(stub, context=None, mw=None, props=None)
+
+ assert set(calls) == {"height", "angle", "roof_thickness"}
+ for name in ("height", "angle", "roof_thickness"):
+ position, _axis = calls[name]
+ assert position.xyz[:] == pytest.approx(
+ (0.0, 0.0, 0.0)
+ ), f"{name} anchored at {position.xyz[:]} instead of object origin"
+ # Axes split the three handles along Z+ (height/slope) vs Z- (thickness)
+ # so they don't visually collide despite sharing the anchor point.
+ assert calls["height"][1] == (0, 0, 1)
+ assert calls["angle"][1] == (0, 0, 1)
+ assert calls["roof_thickness"][1] == (0, 0, -1)
+
+
+# ----------------------------------------------------------------------------
+# Registration smoke test
+# ----------------------------------------------------------------------------
+#
+# Pattern 4 from _shared/bonsai-test-patterns.md: assert the operator is
+# actually registered as ``bim.cycle_roof_generation_method``. Catches
+# ``bl_idname`` typos and missing-from-``classes``-tuple regressions at
+# test time rather than at user-click time (the failure mode otherwise is
+# a silent no-op on the cycle icon, because the gizmo base class skips the
+# icon entirely if its ``cycle_type_operator`` resolves to nothing).
+
+
+def test_cycle_operator_is_registered_under_bim_namespace():
+ assert hasattr(bpy.ops.bim, "cycle_roof_generation_method")