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")