diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 4302739aab..b1e105a079 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import importlib import os @@ -27,6 +29,18 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper from . import handler, operator, prop, ui + +def _parametric_gizmo_preference_classes() -> list[type]: + """Resolves the registry-driven ``GizmoPreferences`` classes for the + ``classes`` list below. ``import bonsai.tool`` is kept local to surface + the load-order constraint: it relies on ``from . import handler, …`` + above having primed the + ``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle.""" + import bonsai.tool as tool + + return tool.Parametric.iter_gizmo_preference_classes(ui) + + try: from bonsai.translations import translations_dict except ImportError: @@ -157,9 +171,10 @@ classes = [ ui.BIM_UL_tab_visibilities, ui.BIM_UL_panel_visibilities, ui.DocPreferences, - ui.GizmoPreferencesDoor, # Register before GizmoPreferences - ui.GizmoPreferencesWindow, # Register before GizmoPreferences - ui.GizmoPreferencesStair, # Register before GizmoPreferences + # Per-parametric-type ``GizmoPreferences`` classes — must register + # before ``ui.GizmoPreferences`` which holds the matching PointerProperty + # fields. Driven by ``tool.Parametric.EDIT_TYPES``. + *_parametric_gizmo_preference_classes(), ui.GizmoPreferences, # ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below) # Tabs panel diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index e11eb07ce8..38c7990653 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -15,11 +15,12 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import os import weakref from collections.abc import Callable -from math import cos from typing import Union import bpy @@ -31,6 +32,7 @@ from bpy.app.handlers import persistent from mathutils import Vector import bonsai.bim +import bonsai.core.model as core_model import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from bonsai.bim.module.aggregate.decorator import AggregateDecorator @@ -133,14 +135,32 @@ def update_bim_tool_props(): if is_annotation_tool and (object_type := tool.Drawing.get_annotation_type_object_type(element_type)): aprops.object_type = object_type - aprops.relating_type_id = str(element_type.id()) + try: + aprops.relating_type_id = str(element_type.id()) + except TypeError: + # EnumProperty items are rebuilt asynchronously when ifc_class changes; + # this assignment can race a stale item list. Skipping is harmless — + # the UI will resync on the next active_object_callback. + pass return if is_bim_tool: props.ifc_class = element_type.is_a() - if is_bim_tool or TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a(): - props.relating_type_id = str(element_type.id()) + # Only assign when the target enum is the one that lists this type — otherwise + # we hit `enum "" not found in (...)` if the user selects an element of a + # different class than the workspace tool was built for (e.g. selecting a wall + # while the door tool is active). + tool_class_match = TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a() + bim_tool_class_match = is_bim_tool and props.ifc_class == element_type.is_a() + if bim_tool_class_match or tool_class_match: + try: + props.relating_type_id = str(element_type.id()) + except TypeError: + # Defensive: the enum item list can lag behind ifc_class assignment + # above. Skipping leaves the panel briefly out of sync rather than + # crashing the handler (which Blender re-fires on every selection). + pass if is_annotation_tool: return @@ -165,7 +185,9 @@ def update_bim_tool_props(): if AuthoringData.data["active_material_usage"] == "LAYER2": x_angle = get_x_angle(extrusion) axis = tool.Model.get_wall_axis(obj)["reference"] - props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle)) + props.extrusion_depth = core_model.vertical_height_from_extrusion_depth( + extrusion.Depth * si_conversion, x_angle + ) props.length = (axis[1] - axis[0]).length props.x_angle = x_angle diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index b07e584a71..453a69058f 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -514,6 +514,7 @@ class IfcStore: BrickStore.end_transaction() IfcStore.end_transaction(operator) bonsai.bim.handler.refresh_ui_data() + tool.Parametric.refresh_post_commit() if method == "MODAL": cls.modal_in_progress = False diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 9f172ce2bb..8b10314faa 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import bpy @@ -143,6 +145,14 @@ classes = ( gizmos.GizmoCancel, gizmos.GizmoPlus, gizmos.GizmoMinus, + gizmos.GizmoMerge, + gizmos.GizmoSplit, + gizmos.GizmoExtend, + gizmos.GizmoExtendVertical, + gizmos.GizmoOffsetExterior, + gizmos.GizmoOffsetCenter, + gizmos.GizmoOffsetInterior, + gizmos.GizmoAddOpening, gizmos.GizmoCycle, # Drawing-specific gizmos gizmos.UglyDotGizmo, diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index d350cf80ee..31a0be5c80 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. """ Gizmo infrastructure for parametric BIM element editing. @@ -511,6 +513,7 @@ class DimensionTextRenderer: color: tuple[float, float, float], offset_sign: int = 1, alignment: TextAlignment | str = TextAlignment.CENTER, + display_text: str | None = None, ) -> None: """Draw formatted dimension value text at the given screen position. @@ -522,15 +525,20 @@ class DimensionTextRenderer: color: Text color (r, g, b) offset_sign: 1 for above/right, -1 for below/left alignment: TextAlignment enum value + display_text: Pre-formatted label. If provided, used verbatim instead of + formatting `value`. """ # Normalize string to enum for comparison if isinstance(alignment, str): alignment = TextAlignment(alignment) - is_negative = value < 0 - text = tool.Unit.format_distance(abs(value)) - if is_negative: - text = "-" + text + if display_text is not None: + text = display_text + else: + is_negative = value < 0 + text = tool.Unit.format_distance(abs(value)) + if is_negative: + text = "-" + text font_id = 0 font_size = tool.Blender.scale_font_size(self.VALUE_FONT_SIZE) @@ -795,6 +803,7 @@ class DimensionRenderer: text_alignment: TextAlignment = TextAlignment.CENTER, prop_name: str | None = None, display_value: float | None = None, + display_text: str | None = None, ) -> None: """Draw complete dimension graphics in screen space. @@ -816,6 +825,8 @@ class DimensionRenderer: text_alignment: TextAlignment enum for text positioning prop_name: Property name for tooltip (shown when highlighted) display_value: Value to display as text (can be negative); uses dimension_length if None + display_text: Pre-formatted label string. If provided, used verbatim instead of + formatting `display_value` via tool.Unit.format_distance. """ if dimension_length < 0: return @@ -935,7 +946,14 @@ class DimensionRenderer: ) text_color = highlight_color if is_highlight else color DimensionTextRenderer.get_instance().draw_value_text( - context, center_screen, perpendicular, text_value, text_color, text_offset_sign, text_alignment + context, + center_screen, + perpendicular, + text_value, + text_color, + text_offset_sign, + text_alignment, + display_text, ) if is_highlight and prop_name: @@ -1121,6 +1139,13 @@ class DimensionGizmoConfig: If provided, eliminates need for get_dimension_matrix_{attr_name} method. The returned Vector is the local-space position where the gizmo origin will be placed. Combined with axis to create the full transformation matrix. + text_formatter: Optional function(props, value) -> str for the dimension label. + Receives the props bag and the post-`compute_value` display value + (i.e. the same number `apply_value` consumes during drag — for the + wall slope gizmo this is the displacement, NOT the underlying + `x_angle`). The raw underlying attribute is accessible as + `getattr(props, attr_name)`. If None, falls back to the default + `tool.Unit.format_distance(abs(value))` with negative-sign handling. """ attr_name: str @@ -1138,6 +1163,7 @@ class DimensionGizmoConfig: apply_value: Callable[[Any, float], None] | None = None visibility_condition: Callable[[Any], bool] | None = None matrix_position: Callable[[Any], "Vector"] | None = None # Optional: function(props) -> Vector position + text_formatter: Callable[[Any, float], str] | None = None # Optional: function(props, value) -> label text def __post_init__(self): # Validate attr_name @@ -1576,6 +1602,78 @@ def get_billboard_rotation(context: bpy.types.Context) -> Matrix: return rv3d.view_matrix.to_3x3().transposed().to_4x4() +def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = 0.5) -> Matrix: + """Compose the standard icon ``matrix_basis``: translate to ``world_pos``, billboard + to the camera, then uniformly scale. Replaces the repeated + ``Matrix.Translation(...) @ billboard_rot @ Matrix.Scale(scale, 4)`` pattern.""" + return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4) + + +def setup_icon_gizmo( + gizmo_group: bpy.types.GizmoGroup, + gizmo_type: str, + color: tuple[float, float, float], + highlight_color: tuple[float, float, float], + operator: str, + alpha: float = 0.8, +) -> bpy.types.Gizmo: + """Create and configure a stand-alone icon gizmo with the Bonsai defaults + (no draw-scale, fixed alpha, click-to-operator). Use this from any + ``GizmoGroup.setup`` to avoid hand-rolling the same five property assignments.""" + gizmo = gizmo_group.gizmos.new(gizmo_type) + gizmo.use_draw_scale = False + gizmo.color = color + gizmo.color_highlight = highlight_color + gizmo.alpha = alpha + gizmo.target_set_operator(operator) + return gizmo + + +# --- Tris geometry helpers ---------------------------------------------------- +# Shared by the icon ``bpy.types.Gizmo`` subclasses defined later in this module. +# Each gizmo declares a flat ``tris`` tuple of (x, y, z) vertices grouped into +# triangles of 3; these helpers compose tris from primitives so the per-gizmo +# definitions stay small and visually readable. + + +def rect_tris(x0: float, y0: float, x1: float, y1: float) -> tuple[tuple[float, float, float], ...]: + """Two triangles forming an axis-aligned rectangle from ``(x0, y0)`` to ``(x1, y1)``, + in the Z=0 plane (the convention for icon gizmos).""" + return ( + (x0, y0, 0.0), + (x0, y1, 0.0), + (x1, y1, 0.0), + (x0, y0, 0.0), + (x1, y1, 0.0), + (x1, y0, 0.0), + ) + + +def swap_xy_tris( + tris: tuple[tuple[float, float, float], ...], +) -> tuple[tuple[float, float, float], ...]: + """Reflect a ``tris`` tuple across the Y=X diagonal — useful when a "vertical" + sibling of a "horizontal" icon should otherwise be a literal copy.""" + return tuple((y, x, z) for x, y, z in tris) + + +class TrisGizmoMixin: + """Mixin for stand-alone ``bpy.types.Gizmo`` classes whose only behaviour is + drawing a static ``tris`` triangle tuple. Subclasses set the class-level + ``tris`` and ``bl_idname`` attributes; the mixin supplies ``setup`` / ``draw`` / + ``draw_select``. Use only with gizmos that have no per-instance state beyond + ``custom_shape``.""" + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self.tris) + + def draw(self, context: bpy.types.Context) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None: """Get normalized direction from position towards camera.""" rv3d = context.region_data @@ -3042,6 +3140,145 @@ class GizmoMinus(bpy.types.Gizmo): self.draw_custom_shape(self.custom_shape, select_id=select_id) +class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo): + """Two arrows pointing inward toward each other — conveys joining/merging elements.""" + + bl_idname = "VIEW3D_GT_merge" + + __slots__ = ("custom_shape",) + + # Two solid triangles pointing toward the center on the horizontal axis, + # plus two thin tails behind each tip to make them read as arrows rather than + # standalone triangles. + tris = ( + # Left arrowhead pointing right (tip at x≈-0.05). + (-0.35, -0.20, 0.0), + (-0.35, 0.20, 0.0), + (-0.05, 0.0, 0.0), + # Left tail behind the arrowhead. + *rect_tris(-0.45, -0.06, -0.30, 0.06), + # Right arrowhead pointing left (tip at x≈0.05). + (0.35, -0.20, 0.0), + (0.35, 0.20, 0.0), + (0.05, 0.0, 0.0), + # Right tail behind the arrowhead. + *rect_tris(0.30, -0.06, 0.45, 0.06), + ) + + +class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo): + """Two arrows pointing outward away from each other — conveys splitting/cutting + one element into two. Visual inverse of `GizmoMerge`.""" + + bl_idname = "VIEW3D_GT_split" + + __slots__ = ("custom_shape",) + + # Two solid triangles pointing OUTWARD on the horizontal axis (tips at x=±0.35), + # with tails extending toward the centerline. The tails meet at center to form a + # short horizontal bar, suggesting the split point itself. + tris = ( + # Left arrowhead pointing left (tip at x=-0.35). + (-0.05, -0.20, 0.0), + (-0.05, 0.20, 0.0), + (-0.35, 0.0, 0.0), + # Left tail extending toward the right (away from the tip, toward center). + *rect_tris(-0.05, -0.06, 0.10, 0.06), + # Right arrowhead pointing right (tip at x=0.35). + (0.05, -0.20, 0.0), + (0.05, 0.20, 0.0), + (0.35, 0.0, 0.0), + # Right tail extending toward the left. + *rect_tris(-0.10, -0.06, 0.05, 0.06), + ) + + +class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo): + """An arrow pointing into a vertical bar — conveys extending an element to a target + line (e.g. extending a wall to the 3D cursor).""" + + bl_idname = "VIEW3D_GT_extend" + + __slots__ = ("custom_shape",) + + # Layout: thick vertical bar at the right edge (the "target") with a horizontal + # arrow pointing into it from the left. + tris = ( + # Vertical target bar (x = 0.25 to 0.35, full height). + *rect_tris(0.25, -0.30, 0.35, 0.30), + # Arrowhead pointing right toward the bar (tip at x=0.20). + (-0.05, -0.18, 0.0), + (-0.05, 0.18, 0.0), + (0.20, 0.0, 0.0), + # Tail extending leftward from the arrowhead base. + *rect_tris(-0.35, -0.06, -0.05, 0.06), + ) + + +class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo): + """Vertical sibling of `GizmoExtend` — arrow pointing UP into a horizontal + bar. Conveys extending an element's height to a target Z.""" + + bl_idname = "VIEW3D_GT_extend_vertical" + + __slots__ = ("custom_shape",) + + # Mechanically derived from GizmoExtend by reflecting across Y=X. + tris = swap_xy_tris(GizmoExtend.tris) + + +def _offset_baseline_tris(mark_x: float) -> tuple[tuple[float, float, float], ...]: + """Shared geometry for the three offset-baseline icons: a horizontal "wall + section" bar with a vertical mark at ``mark_x`` indicating where the reference + axis sits within the wall thickness. Matches the visual convention used in the + Bonsai N-panel's wall Align row.""" + return rect_tris(-0.25, -0.07, 0.25, 0.07) + rect_tris(mark_x - 0.04, -0.22, mark_x + 0.04, 0.22) + + +class GizmoOffsetExterior(TrisGizmoMixin, bpy.types.Gizmo): + """Wall offset baseline indicator — reference axis at the exterior face (left mark).""" + + bl_idname = "VIEW3D_GT_offset_exterior" + __slots__ = ("custom_shape",) + tris = _offset_baseline_tris(-0.24) + + +class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo): + """Wall offset baseline indicator — reference axis at the centreline (middle mark).""" + + bl_idname = "VIEW3D_GT_offset_center" + __slots__ = ("custom_shape",) + tris = _offset_baseline_tris(0.0) + + +class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo): + """Wall offset baseline indicator — reference axis at the interior face (right mark).""" + + bl_idname = "VIEW3D_GT_offset_interior" + __slots__ = ("custom_shape",) + tris = _offset_baseline_tris(0.24) + + +class GizmoAddOpening(TrisGizmoMixin, bpy.types.Gizmo): + """A rectangular frame (square outline with a hole in the middle) — conveys adding an + opening (window/door/void) to a wall.""" + + bl_idname = "VIEW3D_GT_add_opening" + + __slots__ = ("custom_shape",) + + # Outer 0.40 × 0.40 square with a 0.25 × 0.25 inner hole, drawn as four bars + # forming a frame, plus a small "+" in the inner hole to convey "add". + tris = ( + *rect_tris(-0.20, 0.125, 0.20, 0.20), # Top bar + *rect_tris(-0.20, -0.20, 0.20, -0.125), # Bottom bar + *rect_tris(-0.20, -0.125, -0.125, 0.125), # Left bar + *rect_tris(0.125, -0.125, 0.20, 0.125), # Right bar + *rect_tris(-0.07, -0.015, 0.07, 0.015), # "+" horizontal stroke + *rect_tris(-0.015, -0.07, 0.015, 0.07), # "+" vertical stroke + ) + + def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]: """Generate circular arrow geometry covering ~300 degrees.""" triangles = [] @@ -3421,6 +3658,7 @@ class GizmoDimension(GizmoMovable): "_original_value", # Original property value before interaction "_click_offset", # Offset from dimension tip to click position (for snap correction) "show_extension_lines", # Whether to show extension lines at dimension endpoints + "text_formatter", # Optional (props, value) -> str to override the default dimension label ) ARROW_SIZE = 10 @@ -3479,6 +3717,16 @@ class GizmoDimension(GizmoMovable): start_world = self.matrix_basis.translation.copy() end_world = start_world + axis_world * self._dimension_length + display_value = getattr(self, "_display_value", self._dimension_length) + text_formatter = getattr(self, "text_formatter", None) + gizmo_group = getattr(self, "gizmo_group", None) + display_text: str | None = None + if text_formatter is not None and gizmo_group is not None: + obj = bpy.context.active_object + props = gizmo_group.get_props(obj) if obj is not None else None + if props is not None: + display_text = text_formatter(props, display_value) + DimensionRenderer.get_instance().draw( context=context, start_world=start_world, @@ -3496,7 +3744,8 @@ class GizmoDimension(GizmoMovable): text_offset_sign=getattr(self, "text_offset_sign", 1), text_alignment=getattr(self, "text_alignment", TextAlignment.CENTER), prop_name=getattr(self, "prop_name", None), - display_value=getattr(self, "_display_value", self._dimension_length), + display_value=display_value, + display_text=display_text, ) def _calculate_screen_endpoints(self, context: bpy.types.Context) -> tuple[Vector, Vector, Vector, float] | None: @@ -3615,6 +3864,11 @@ class GizmoDimension(GizmoMovable): self._display_value = max(-10000.0, min(length, 10000.0)) # Clamp to valid range (0 to 10000 meters is reasonable for BIM) for drawing self._dimension_length = max(0.0, min(abs(length), 10000.0)) + # Smaller dimensions win selection when hit regions overlap: a long gizmo's + # hit box fully contains a nested short one's, so without a bias the long + # one wins and the short one is unreachable. The long one stays clickable + # at its exposed ends regardless of bias. + self.select_bias = -self._dimension_length def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: """Initialize dimension gizmo interaction with click-position tracking. @@ -3913,6 +4167,59 @@ class CycleTypeMixin: return {"FINISHED"} +class BillboardingGizmoGroupMixin: + """Mixin for standalone ``bpy.types.GizmoGroup`` classes whose icons must billboard + (face the camera) and re-position every frame. + + Blender calls ``GizmoGroup.refresh()`` only on state-change events (selection, + property change, dependency update) — not on camera rotation. A gizmo group that + only sets ``matrix_basis`` in ``refresh()`` will appear to "freeze" its rotation + at the camera angle in effect when it was last refreshed; orbiting the camera + leaves the icon facing the wrong way. + + ``draw_prepare()`` *is* called every redraw, so the fix is to run the same + positioning code from both events. Rather than overriding ``refresh()`` and + ``draw_prepare()`` in every gizmo group that has this need, subclass this mixin + and implement a single ``position_gizmos(context)`` method. + + Usage:: + + class MyGizmoGroup(bpy.types.GizmoGroup, BillboardingGizmoGroupMixin): + bl_idname = "..." + ... + def setup(self, context): + ... + def position_gizmos(self, context): + # set matrix_basis on every gizmo here, using get_billboard_rotation + # for any icon that should face the camera. + ... + + ``position_gizmos`` should be idempotent — it's called twice when a state change + coincides with a redraw (once via ``refresh``, once via ``draw_prepare``).""" + + 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 setup_icon_gizmo( + self, + gizmo_type: str, + color: tuple[float, float, float], + highlight_color: tuple[float, float, float], + operator: str, + alpha: float = 0.8, + ) -> bpy.types.Gizmo: + """Convenience wrapper over `setup_icon_gizmo` for subclasses.""" + return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha) + + def position_gizmos(self, context: bpy.types.Context) -> None: + raise NotImplementedError( + f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin." + ) + + class BaseParametricGizmoGroup: """Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.). @@ -4129,6 +4436,32 @@ class BaseParametricGizmoGroup: return width + (self.GIZMO_OFFSET if use_offset else 0) return -self.GIZMO_OFFSET if use_offset else 0 + @staticmethod + def get_camera_facing_outer_y( + viewing_from_negative_y: bool, + near_y: float, + far_y: float, + gizmo_offset: float = 0.0, + ) -> float: + """Y coordinate just outside the camera-facing face of an element. + + Generalises `get_y_position_for_view` for elements whose near face + isn't at the local origin. ``near_y`` is the local-Y of the -Y face; + ``far_y`` is the local-Y of the +Y face. Returns the Y just *outside* the + face the camera is currently looking at, pushed by ``gizmo_offset`` (use + ``cls.GIZMO_OFFSET`` for the standard handle gap). + + Suits walls (``near_y = props.offset``, ``far_y = props.offset + props.thickness``) + and any other element whose section sits inside a non-zero Y band. Stair / + door / window can also call this once their callers pass explicit near/far + instead of the implicit ``width_attr`` pattern, eliminating + ``get_y_position_for_view``, ``get_lining_y_position_for_view`` etc. as + wrappers around the same shape — but they're left intact for now to avoid + churning code paths that already work.""" + if viewing_from_negative_y: + return near_y - gizmo_offset + return far_y + gizmo_offset + def get_icon_y_for_view(self, props, viewing_from_negative_y: bool) -> float: """Get Y position for editing icons based on view direction. @@ -4224,13 +4557,13 @@ class BaseParametricGizmoGroup: """ return 0.0 - def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: # noqa: ARG002 """Update overall_width, overall_height, and lining_offset based on view direction. This base implementation handles the common pattern for door/window gizmos. Subclasses can override get_casing_offset() to customize behavior. """ - viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) + viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y) self.set_dimension_gizmo_position("overall_width", mw, Vector((0, y_pos, -self.GIZMO_OFFSET)), (1, 0, 0)) @@ -4309,21 +4642,15 @@ class BaseParametricGizmoGroup: @classmethod def poll(cls, context) -> bool: - prefs = tool.Blender.get_addon_preferences() - if not prefs.gizmos.draw_gizmos_in_3d_viewport: - return False - obj = tool.Blender.get_active_object(is_selected=True) - if not obj: + if obj is None: + return False + if not tool.Blender.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport: return False - if len(tool.Blender.get_selected_objects()) != 1: return False - element = tool.Ifc.get_entity(obj) - if not element or not cls.is_element_type(element): - return False - return True + return bool(element) and cls.is_element_type(element) def setup(self, context: bpy.types.Context) -> None: """Template method for gizmo setup. @@ -4343,6 +4670,19 @@ class BaseParametricGizmoGroup: """ pass + # Frame-scoped caches primed at the top of ``refresh()`` and ``draw_prepare()``. + # Every per-frame helper — preferences access, view-direction lookup, billboard + # rotation — reads these instead of re-deriving the same values, since each + # gizmo group ends up needing them 2–5× per frame across its position helpers. + _frame_prefs: Any = None + _frame_view_dir: tuple[bool, bool] | None = None + _frame_billboard_rot: "Matrix | None" = None + + def _prime_frame_caches(self, context: bpy.types.Context, mw: "Matrix") -> None: + self._frame_prefs = tool.Blender.get_addon_preferences() + self._frame_view_dir = self.get_local_view_direction(context, mw) + self._frame_billboard_rot = get_billboard_rotation(context) + def refresh(self, context: bpy.types.Context) -> None: """Template method for gizmo refresh. @@ -4357,6 +4697,7 @@ class BaseParametricGizmoGroup: props = self.get_props(obj) mw = obj.matrix_world + self._prime_frame_caches(context, mw) self.update_editing_gizmos(context, mw, props) self.update_dimension_gizmos(mw, props) self._refresh_element_specific(context, mw, props) @@ -4364,8 +4705,10 @@ class BaseParametricGizmoGroup: def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: # noqa: ARG002 """Override for element-specific refresh logic. - Called after update_editing_gizmos and update_dimension_gizmos. - Examples: door swing gizmos, stair lock/tread/plus/minus gizmos. + Called from both refresh() (on state change) and draw_prepare() (per frame), + so any override must be idempotent and cheap. Use this to re-position or + re-billboard element-specific gizmos (door swing arcs, stair lock/+/- icons, + wall cursor icons, etc.). """ pass @@ -4385,10 +4728,11 @@ class BaseParametricGizmoGroup: return getattr(tool.Model, self.props_getter)(obj) raise NotImplementedError("Subclass must define props_getter or override get_props()") - @staticmethod - def get_addon_prefs(): - """Get addon preferences (cached accessor).""" - return tool.Blender.get_addon_preferences() + def get_addon_prefs(self): + """Return the addon preferences struct. Inside ``refresh`` / ``draw_prepare`` + the frame cache is hit; outside (e.g. ``setup``) we fall through to a fresh + lookup so callers don't have to know which call path they're on.""" + return self._frame_prefs if self._frame_prefs is not None else tool.Blender.get_addon_preferences() def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: """Get default and highlight colors from preferences. @@ -4507,8 +4851,8 @@ class BaseParametricGizmoGroup: scale: Gizmo scale factor (default 0.5) """ if gz := self.get_gizmo_if_visible(gizmo_name): - local_transform = Matrix.Translation(Vector((x, y, z))) @ billboard_rot @ Matrix.Scale(scale, 4) - gz.matrix_basis = mw @ local_transform + world_pos = mw @ Vector((x, y, z)) + gz.matrix_basis = billboarded_at(world_pos, billboard_rot, scale) def set_dimension_gizmo_position( self, @@ -4594,28 +4938,12 @@ class BaseParametricGizmoGroup: ) -> bpy.types.Gizmo: """Create and configure an icon gizmo with standard settings. - Reduces boilerplate in setup_editing_gizmos. - - Args: - gizmo_type: Blender gizmo type identifier (e.g., "VIEW3D_GT_pen") - color: RGB color tuple - operator: Operator to invoke on click - highlight_color: Optional highlight color (defaults to prefs selection color) - alpha: Gizmo alpha (default 0.8) - - Returns: - Configured gizmo instance. + Thin wrapper over `setup_icon_gizmo` that defaults ``highlight_color`` + to the addon-prefs selection color via ``get_decoration_colors``. """ if highlight_color is None: _, highlight_color = self.get_decoration_colors() - - gizmo = self.gizmos.new(gizmo_type) - gizmo.use_draw_scale = False - gizmo.color = color - gizmo.color_highlight = highlight_color - gizmo.alpha = alpha - gizmo.target_set_operator(operator) - return gizmo + return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha) def setup_editing_gizmos(self, context: bpy.types.Context) -> None: default_color, highlight_color = self.get_decoration_colors() @@ -4696,6 +5024,7 @@ class BaseParametricGizmoGroup: gizmo.delta_scale = config.delta_scale gizmo.prop_name = config.prop_name # Auto-derived in __post_init__ gizmo.gizmo_group = self + gizmo.text_formatter = config.text_formatter gizmo.color = self.get_color_from_name(config.color) gizmo.color_highlight = highlight_color gizmo.alpha = 1.0 @@ -4723,10 +5052,9 @@ class BaseParametricGizmoGroup: gizmo.hide = False - # Priority: config.matrix_position > get_dimension_matrix_* method > Identity + # Priority: config.matrix_position > get_dimension_matrix_* method > Identity. if config.matrix_position: - position = config.matrix_position(props) - base_matrix = self.compose_gizmo_matrix(position, config.axis) + base_matrix = self.compose_gizmo_matrix(config.matrix_position(props), config.axis) else: matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None) base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4) @@ -4758,7 +5086,7 @@ class BaseParametricGizmoGroup: """ return (0.0, 0.0) - def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: + def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: # noqa: ARG002 """Get Y offset for icons based on view direction. Uses get_icon_y_extent() to determine how far to offset icons based on @@ -4774,8 +5102,7 @@ class BaseParametricGizmoGroup: props = self.get_props(obj) positive_extent, negative_extent = self.get_icon_y_extent(props) - viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) - if viewing_from_negative_y: + if self._frame_view_dir[0]: return -negative_extent return positive_extent @@ -4783,34 +5110,40 @@ class BaseParametricGizmoGroup: """Update editing icon gizmo positions to billboard toward camera.""" icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET icon_y = self.get_icon_y_offset(context, mw) - billboard_rot = get_billboard_rotation(context) - - # This ensures icons face camera regardless of object rotation - local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z)) - world_pos_validate = mw @ local_pos_validate - - icon_matrix_base = Matrix.Translation(world_pos_validate) @ billboard_rot @ Matrix.Scale(0.5, 4) - + billboard_rot = self._frame_billboard_rot + # set_icon_gizmo_position no-ops on hidden gizmos (via get_gizmo_if_visible), + # so the hide flag must be set first; that gates whether the matrix is written. if props.is_editing: self.pen_gizmo.hide = True self.validate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.validate_gizmo) - self.validate_gizmo.matrix_basis = icon_matrix_base - + self.set_icon_gizmo_position( + "validate_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot + ) self.cancel_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cancel_gizmo) - local_pos_cancel = Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z)) - world_pos_cancel = mw @ local_pos_cancel - self.cancel_gizmo.matrix_basis = Matrix.Translation(world_pos_cancel) @ billboard_rot @ Matrix.Scale(0.5, 4) - + self.set_icon_gizmo_position( + "cancel_gizmo", + mw=mw, + x=self.ICON_VALIDATE_X + self.ICON_CANCEL_X, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + ) if self.cycle_type_operator: self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo) - local_pos_cycle = Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z)) - world_pos_cycle = mw @ local_pos_cycle - self.cycle_gizmo.matrix_basis = ( - Matrix.Translation(world_pos_cycle) @ billboard_rot @ Matrix.Scale(0.30, 4) + self.set_icon_gizmo_position( + "cycle_gizmo", + mw=mw, + x=self.ICON_VALIDATE_X + self.ICON_CYCLE_X, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=0.30, ) else: self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) - self.pen_gizmo.matrix_basis = icon_matrix_base + self.set_icon_gizmo_position( + "pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot + ) self.validate_gizmo.hide = True self.cancel_gizmo.hide = True if self.cycle_type_operator: @@ -4819,16 +5152,26 @@ class BaseParametricGizmoGroup: def draw_prepare(self, context: bpy.types.Context) -> None: """Called before drawing - updates gizmos to face camera. - This method updates editing gizmos and dimension gizmos. - Subclasses can override _update_dimension_gizmo_positions() to customize - dimension gizmo positioning based on view direction. + This method updates editing gizmos, dimension gizmos, and element-specific + gizmos. Subclasses can override _update_dimension_gizmo_positions() to + customize dimension gizmo positioning, and _refresh_element_specific() to + re-billboard element-specific gizmos per frame. """ obj = context.active_object if not obj: return props = self.get_props(obj) mw = obj.matrix_world + self._prime_frame_caches(context, mw) self.update_editing_gizmos(context, mw, props) + # `update_dimension_gizmos` flips the dimension gizmos' `hide` flag + # based on `props.is_editing` + per-config visibility conditions. + # `refresh()` already calls it, but `refresh()` only fires on depsgraph + # events — a `finish_editing_*` operator that toggles `is_editing` to + # False without mutating IFC (e.g. wall no-op commit, cancel) does not + # trigger a depsgraph update, so without this call the dimension gizmos + # would stay visible until the next user input. + self.update_dimension_gizmos(mw, props) self._update_dimension_gizmo_positions(context, mw, props) @@ -4836,6 +5179,8 @@ class BaseParametricGizmoGroup: for _, gizmo in self.iter_visible_dimension_gizmos(): gizmo.draw_prepare(context) + self._refresh_element_specific(context, mw, props) + def _update_dimension_gizmo_positions( self, context: bpy.types.Context, mw: "Matrix", props # noqa: ARG002 ) -> None: diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 9fbd631003..26dca1984d 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -15,11 +15,15 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from typing import NamedTuple import bpy +import bonsai.tool as tool + from . import ( array, covering, @@ -70,18 +74,32 @@ classes = ( workspace.BIM_MT_add_representation_item, wall.AddWallsFromSlab, wall.AlignWall, + wall.CancelEditingWall, wall.ChangeExtrusionDepth, wall.ChangeExtrusionXAngle, wall.ChangeLayerLength, + wall.CycleWallOffset, wall.DrawPolylineWall, + wall.EnableEditingWall, + wall.ExtendWallHeightToCursor, wall.ExtendWallsToUnderside, wall.ExtendWallsToWall, wall.ExtendWallsToPolylinePoint, + wall.ExtendWallToCursor, + wall.FinishEditingWall, wall.FlipWall, + wall.GizmoWallAddOpening, + wall.GizmoWallEdition, + wall.GizmoWallExtendVertically, + wall.GizmoWallJoinIntersection, + wall.JoinWallsIntersection, wall.MergeWall, wall.OffsetWalls, wall.RecalculateWall, + wall.RotateWall90, wall.SplitWall, + wall.SplitWallAtCursor, + wall.ToggleWallOpenings, wall.UnjoinWalls, opening.AddBoolean, opening.CloneOpening, @@ -140,10 +158,12 @@ classes = ( prop.BIMDoorProperties, prop.BIMRailingProperties, prop.BIMRoofProperties, + prop.BIMWallProperties, prop.BIMPolylineProperties, prop.BIMExternalParametricGeometryProperties, ui.BIM_PT_array, ui.BIM_PT_stair, + ui.BIM_PT_wall, ui.BIM_PT_sverchok, ui.BIM_PT_window, ui.BIM_PT_door, @@ -264,12 +284,10 @@ def register(): bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties) bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties) bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties) - bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties) bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties) - bpy.types.Object.BIMWindowProperties = bpy.props.PointerProperty(type=prop.BIMWindowProperties) - bpy.types.Object.BIMDoorProperties = bpy.props.PointerProperty(type=prop.BIMDoorProperties) - bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties) - bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties) + # Per-parametric-type ``BIMProperties`` PointerProperties — driven by + # ``tool.Parametric.EDIT_TYPES``; adding a registry entry is the single touchpoint. + tool.Parametric.register_object_properties(prop) bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty( type=prop.BIMExternalParametricGeometryProperties ) @@ -288,12 +306,8 @@ def unregister(): del bpy.types.Scene.BIMModelProperties del bpy.types.Scene.BIMPolylineProperties del bpy.types.Object.BIMArrayProperties - del bpy.types.Object.BIMStairProperties del bpy.types.Object.BIMSverchokProperties - del bpy.types.Object.BIMWindowProperties - del bpy.types.Object.BIMDoorProperties - del bpy.types.Object.BIMRailingProperties - del bpy.types.Object.BIMRoofProperties + tool.Parametric.unregister_object_properties() del bpy.types.Object.BIMExternalParametricGeometryProperties bpy.app.handlers.load_post.remove(handler.load_post) diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 5a14cde101..d6a619f429 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -38,6 +38,7 @@ 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.window import create_bm_box, create_bm_window +from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin if TYPE_CHECKING: from bonsai.bim.module.model.prop import BIMDoorProperties @@ -566,103 +567,58 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator): +class _DoorEditMixin(FeatureModifierEditMixin): + """Type-specific hooks for door parametric-edit operators. Multi-object — + iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies + to every selected door at once.""" + + pset_name = "BBIM_Door" + + @classmethod + def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]: + return tool.Blender.get_selected_objects() + + @classmethod + def _is_element_type(cls, element): + return tool.Blender.Modifier.is_door(element) + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_door_props(obj) + + @classmethod + def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + update_door_modifier_representation(obj) + + +class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.cancel_editing_door" bl_label = "Cancel Editing Door on Selected Objects" bl_description = "Cancel editing and revert door parameters to their previous values" bl_options = {"REGISTER", "UNDO"} - def cancel_editing_door_on_object(self, obj: bpy.types.Object) -> None: - element = tool.Ifc.get_entity(obj) - assert element - if not tool.Blender.Modifier.is_door(element): - return - props = tool.Model.get_door_props(obj) - data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data")) - data.update(data.pop("lining_properties")) - data.update(data.pop("panel_properties")) - - # restore previous settings since editing was canceled - props.set_props_kwargs_from_ifc_data(data) - - body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - core.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=body, - ) - - props.is_editing = False - - def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 - for obj in tool.Blender.get_selected_objects(): - self.cancel_editing_door_on_object(obj) - return {"FINISHED"} + def _execute(self, context: bpy.types.Context) -> set[str]: + return self._cancel_targets(context) -class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): +class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.finish_editing_door" bl_label = "Finish Editing Door on Selected Objects" bl_description = "Apply changes and finish editing door parameters" bl_options = {"REGISTER", "UNDO"} - def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None: - element = tool.Ifc.get_entity(obj) - assert element - if not tool.Blender.Modifier.is_door(element): - return - props = tool.Model.get_door_props(obj) - - door_data = props.get_general_kwargs(convert_to_project_units=True) - lining_props = props.get_lining_kwargs(convert_to_project_units=True) - panel_props = props.get_panel_kwargs(convert_to_project_units=True) - - door_data["lining_properties"] = lining_props - door_data["panel_properties"] = panel_props - - props.is_editing = False - - update_door_modifier_representation(obj) - element_type = ifcopenshell.util.element.get_type(element) - if element_type: - tool.Model.mark_thumbnail_for_update(element_type) - - pset = tool.Pset.get_element_pset(element, "BBIM_Door") - door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list)) - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data}) - - def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 - for obj in tool.Blender.get_selected_objects(): - self.finish_editing_door_on_object(obj) - return {"FINISHED"} + def _execute(self, context: bpy.types.Context) -> set[str]: + return self._finish_targets(context) -class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_door" bl_label = "Enable Editing Door on Selected Objects" bl_description = "Enter edit mode to modify door parameters interactively" bl_options = {"REGISTER", "UNDO"} - def edit_door_on_obj(self, obj: bpy.types.Object) -> None: - element = tool.Ifc.get_entity(obj) - assert element - if not tool.Blender.Modifier.is_door(element): - return - props = tool.Model.get_door_props(obj) - data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data")) - data.update(data.pop("lining_properties")) - data.update(data.pop("panel_properties")) - data.update(tool.Model.get_constituents_props_data(element)) - - # required since we could load pset from .ifc and BIMDoorProperties won't be set - props.set_props_kwargs_from_ifc_data(data) - props.is_editing = True - - def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 - for obj in tool.Blender.get_selected_objects(): - self.edit_door_on_obj(obj) - return {"FINISHED"} + def _execute(self, context: bpy.types.Context) -> set[str]: + return self._enable_targets(context) class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator): @@ -939,7 +895,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None: """Update swing gizmo position and color based on editing state.""" - prefs = tool.Blender.get_addon_preferences() + prefs = self.get_addon_prefs() door_gizmo_prefs = prefs.gizmos.door door_type_visible = self.update_gizmo_visibility( diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index ff6ea96130..77f4b8a9f2 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import math from collections.abc import Callable @@ -193,6 +195,32 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None _get_updater("stair", "regenerate_stair_mesh")(obj) +def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None: + """Regenerate wall mesh preview when property changes. Does NOT touch IFC.""" + obj = context.active_object + if obj and self.is_editing: + _get_updater("wall", "regenerate_wall_mesh_from_props")(obj) + + +def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None: + """Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC. + + ``offset`` itself has no ``update`` callback on purpose — adding one would make + every baseline cycle rebuild the bmesh twice (once via offset's callback, once + explicitly below).""" + obj = context.active_object + if not (obj and self.is_editing): + return + t = self.thickness + if self.desired_offset_baseline == "CENTER": + self.offset = -t / 2 + elif self.desired_offset_baseline == "INTERIOR": + self.offset = -t + else: # EXTERIOR + self.offset = 0.0 + _get_updater("wall", "regenerate_wall_mesh_from_props")(obj) + + def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None: """Regenerate railing mesh when property changes.""" if self.is_editing: @@ -1631,6 +1659,118 @@ class BIMRoofProperties(PropertyGroup): setattr(target_props, prop_name, prop_value) +class BIMWallProperties(PropertyGroup): + """Transient draft state for parametric wall gizmo editing. + + Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit + (preview only — no IFC writes), and either committed by `bim.finish_editing_wall` + or discarded by `bim.cancel_editing_wall`. + + The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares + current vs snap to skip unchanged params and guarantee a no-op session leaves the + IFC file byte-identical. + """ + + is_editing: bpy.props.BoolProperty( + default=False, + description="True while wall parametric edit mode is active.", + ) + mesh_dirty: bpy.props.BoolProperty( + default=False, + options={"HIDDEN", "SKIP_SAVE"}, + description=( + "True while the visible mesh is the preview box; cleared once the real " + "IFC-derived geometry is restored (on commit or cancel)." + ), + ) + length: bpy.props.FloatProperty( + name="Length", + default=1.0, + min=0.01, + subtype="DISTANCE", + update=update_wall, + description="Wall length along its reference axis (preview value; committed on finish).", + ) + height: bpy.props.FloatProperty( + name="Height", + default=3.0, + min=0.01, + subtype="DISTANCE", + update=update_wall, + description="Wall vertical height (preview value; committed on finish).", + ) + x_angle: bpy.props.FloatProperty( + name="Slope (X Angle)", + default=0.0, + soft_min=-math.pi / 3, + soft_max=math.pi / 3, + subtype="ANGLE", + update=update_wall, + description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).", + ) + thickness: bpy.props.FloatProperty( + name="Thickness", + default=0.2, + min=0.001, + subtype="DISTANCE", + description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.", + ) + offset: bpy.props.FloatProperty( + name="Offset", + default=0.0, + subtype="DISTANCE", + description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.", + ) + desired_offset_baseline: bpy.props.EnumProperty( + items=[ + ("EXTERIOR", "Exterior", "Reference axis at the exterior face"), + ("CENTER", "Center", "Reference axis at the wall centreline"), + ("INTERIOR", "Interior", "Reference axis at the interior face"), + ], + name="Desired Offset Baseline", + default="CENTER", + update=update_wall_offset_baseline, + description="Which face of the wall the reference axis aligns to (preview value; committed on finish).", + ) + anchor_x: bpy.props.FloatProperty( + default=0.0, + subtype="DISTANCE", + description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.", + ) + + snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.") + snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.") + snap_thickness: bpy.props.FloatProperty( + description="Snapshot of thickness at edit-enable; commit skips no-op writes." + ) + snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.") + snap_x_angle: bpy.props.FloatProperty( + subtype="ANGLE", + description="Snapshot of x_angle at edit-enable; commit skips no-op writes.", + ) + snap_offset_baseline: bpy.props.StringProperty( + default="", + description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.", + ) + + if TYPE_CHECKING: + is_editing: bool + mesh_dirty: bool + length: float + height: float + x_angle: float + thickness: float + offset: float + desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"] + anchor_x: float + snap_length: float + snap_height: float + snap_thickness: float + snap_offset: float + snap_x_angle: float + snap_offset_baseline: str + + class SnapMousePoint(PropertyGroup): x: bpy.props.FloatProperty(name="X") y: bpy.props.FloatProperty(name="Y") diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 7ca66d8dbc..641674b060 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -34,6 +34,7 @@ import bonsai.core.root import bonsai.tool as tool from bonsai.bim.module.model.data import RailingData, refresh from bonsai.bim.module.model.decorator import ProfileDecorator +from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin # reference: # https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm @@ -406,66 +407,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.enable_editing_railing" - bl_label = "Enable Editing Railing" - bl_options = {"REGISTER"} +class _RailingEditMixin(PathPreservingEditMixin): + """Type-specific hooks for railing parametric-edit operators. Single-object + (active_object). ``path_data`` is preserved through the edit; the separate + ``Enable/Finish/CancelEditingRailingPath`` operators handle path editing.""" - def _execute(self, context): - obj = context.active_object - assert obj - props = tool.Model.get_railing_props(obj) - data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"] + pset_name = "BBIM_Railing" + + @classmethod + def _is_element_type(cls, element): + return tool.Blender.Modifier.is_railing(element) + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_railing_props(obj) + + @classmethod + def _post_load_data(cls, data: dict) -> dict: + # BIMRailingProperties.path_data is a StringProperty holding JSON. data["path_data"] = json.dumps(data["path_data"]) + return data - # required since we could load pset from .ifc and BIMRailingProperties won't be set - props.set_props_kwargs_from_ifc_data(data) + @classmethod + def _update_pset(cls, element, data: dict) -> None: + update_bbim_railing_pset(element, data) - props.is_editing = True - return {"FINISHED"} + @classmethod + def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + update_railing_modifier_ifc_data(context) - -class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.cancel_editing_railing" - bl_label = "Cancel Editing Railing" - bl_options = {"REGISTER"} - - def _execute(self, context): - obj = context.active_object - assert obj - data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"] - props = tool.Model.get_railing_props(obj) - - # restore previous settings since editing was canceled - props.set_props_kwargs_from_ifc_data(data) + @classmethod + def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: update_railing_modifier_bmesh(context) - props.is_editing = False - return {"FINISHED"} - -class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.finish_editing_railing" - bl_label = "Finish Editing Railing" - bl_options = {"REGISTER"} +class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.enable_editing_railing" + bl_label = "Enable Editing Railing" + bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - obj = context.active_object - assert obj - element = tool.Ifc.get_entity(obj) - assert element - props = tool.Model.get_railing_props(obj) + return self._enable_targets(context) - pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing") - path_data = pset_data["data_dict"]["path_data"] - railing_data = props.get_general_kwargs(convert_to_project_units=True) - railing_data["path_data"] = path_data - props.is_editing = False +class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.cancel_editing_railing" + bl_label = "Cancel Editing Railing" + bl_options = {"REGISTER", "UNDO"} - update_bbim_railing_pset(element, railing_data) - update_railing_modifier_ifc_data(context) - return {"FINISHED"} + def _execute(self, context): + return self._cancel_targets(context) + + +class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.finish_editing_railing" + bl_label = "Finish Editing Railing" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + return self._finish_targets(context) class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py index e1f7903299..b949827ed2 100644 --- a/src/bonsai/bonsai/bim/module/model/roof.py +++ b/src/bonsai/bonsai/bim/module/model/roof.py @@ -34,6 +34,7 @@ import bonsai.core.root import bonsai.tool as tool from bonsai.bim.module.model.data import RoofData, refresh from bonsai.bim.module.model.decorator import ProfileDecorator +from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin # reference: # https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm @@ -608,61 +609,59 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator): tool.Model.add_body_representation(obj) -class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.enable_editing_roof" - bl_label = "Enable Editing Roof" - bl_options = {"REGISTER"} +class _RoofEditMixin(PathPreservingEditMixin): + """Type-specific hooks for roof parametric-edit operators. Single-object + (active_object). ``path_data`` is preserved through the edit; the separate + ``Enable/Finish/CancelEditingRoofPath`` operators handle path editing.""" - def _execute(self, context): - obj = context.active_object - assert obj - props = tool.Model.get_roof_props(obj) - data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"] - # required since we could load pset from .ifc and BIMRoofProperties won't be set - props.set_props_kwargs_from_ifc_data(data) - props.is_editing = True - return {"FINISHED"} + pset_name = "BBIM_Roof" + @classmethod + def _is_element_type(cls, element): + return tool.Blender.Modifier.is_roof(element) -class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.cancel_editing_roof" - bl_label = "Cancel Editing Roof" - bl_options = {"REGISTER"} + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_roof_props(obj) - def _execute(self, context): - obj = context.active_object - assert obj - data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"] - props = tool.Model.get_roof_props(obj) + @classmethod + def _update_pset(cls, element, data: dict) -> None: + update_bbim_roof_pset(element, data) - # restore previous settings since editing was canceled - props.set_props_kwargs_from_ifc_data(data) + @classmethod + def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + update_roof_modifier_ifc_data(context) + + @classmethod + def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: update_roof_modifier_bmesh(obj) - props.is_editing = False - return {"FINISHED"} - -class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.finish_editing_roof" - bl_label = "Finish Editing Roof" - bl_options = {"REGISTER"} +class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.enable_editing_roof" + bl_label = "Enable Editing Roof" + bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - obj = context.active_object - element = tool.Ifc.get_entity(obj) - props = tool.Model.get_roof_props(obj) + return self._enable_targets(context) - pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof") - path_data = pset_data["data_dict"]["path_data"] - roof_data = props.get_general_kwargs(convert_to_project_units=True) - roof_data["path_data"] = path_data - props.is_editing = False +class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.cancel_editing_roof" + bl_label = "Cancel Editing Roof" + bl_options = {"REGISTER", "UNDO"} - update_bbim_roof_pset(element, roof_data) - update_roof_modifier_ifc_data(context) - return {"FINISHED"} + def _execute(self, context): + return self._cancel_targets(context) + + +class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.finish_editing_roof" + bl_label = "Finish Editing Roof" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + return self._finish_targets(context) class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 87152c645b..0834263552 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import json @@ -262,7 +264,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): # Use the special method that includes custom_tread_lock for IFC storage data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True) - props.is_editing = False regenerate_stair_mesh(obj) tool.Model.add_body_representation(obj) @@ -272,6 +273,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): # update IfcStairFlight properties update_ifc_stair_props(obj) + props.is_editing = False return {"FINISHED"} @@ -608,29 +610,23 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): "VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1 ) - def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None: - """Update stair-specific lock and tread count gizmos.""" - billboard_rot = gizmo.get_billboard_rotation(context) - self.update_lock_gizmo(mw, props, billboard_rot) + def _refresh_element_specific( + self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002 + ) -> None: + """Update stair-specific lock and tread count gizmos. Lock positioning is + handled per-frame in the dimension-positioning hook.""" + self.update_lock_gizmo(props) self.update_tread_lock_gizmo(props) self.update_tread_count_gizmos(props) - def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None: - """Update lock gizmo visibility, color, and position.""" + def update_lock_gizmo(self, props: "BIMStairProperties") -> None: + """Update lock gizmo color and visibility. Positioning is handled + per-frame by the dimension-positioning hook.""" gizmo_prefs = self.get_gizmo_prefs() if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock): - return # Hidden, skip positioning - + return # Hidden, skip color update self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN - total_run = props.get_total_run() - local_transform = ( - Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET))) - @ billboard_rot - @ Matrix.Scale(self.EDITING_ICON_SCALE, 4) - ) - self.lock_gizmo.matrix_basis = mw @ local_transform - def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None: """Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions.""" if not hasattr(self, "tread_lock_gizmo"): @@ -650,11 +646,11 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ) def _update_dimension_gizmo_positions( - self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" + self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002 ) -> None: """Update dimension gizmo positions based on camera view direction.""" - viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) - billboard_rot = gizmo.get_billboard_rotation(context) + viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir + billboard_rot = self._frame_billboard_rot total_run = props.get_total_run() riser_height = props.get_riser_height() diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index eef71ed433..7775bdd67b 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -338,6 +338,36 @@ class BIM_PT_stair(bpy.types.Panel): row.operator("bim.add_stair", icon="ADD", text="") +class BIM_PT_wall(bpy.types.Panel): + bl_label = "Wall" + bl_idname = "BIM_PT_wall" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_options = {"DEFAULT_CLOSED"} + bl_parent_id = "BIM_PT_tab_parametric_geometry" + + @classmethod + def poll(cls, context): + obj = context.active_object + if not obj: + return False + element = tool.Ifc.get_entity(obj) + return bool(element) and tool.Blender.Modifier.is_wall(element) + + def draw(self, context): + obj = context.active_object + if obj is None: + return + props = tool.Model.get_wall_props(obj) + row = self.layout.row(align=True) + if props.is_editing: + row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing") + row.operator("bim.cancel_editing_wall", icon="CANCEL", text="") + else: + row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall") + + class BIM_PT_sverchok(bpy.types.Panel): bl_label = "Sverchok" bl_idname = "BIM_PT_sverchok" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index dd900f4a23..b923aa3446 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -16,13 +16,16 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . # +# This file was modified with the assistance of an AI coding tool. +# # pyright: reportUnnecessaryTypeIgnoreComment=error import copy import math from math import atan2, cos, degrees, pi, sin -from typing import TYPE_CHECKING, Any, Literal, Union, get_args +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, get_args +import bmesh import bpy import ifcopenshell import ifcopenshell.api.feature @@ -46,9 +49,121 @@ import bonsai.core.model as core import bonsai.core.root import bonsai.tool as tool from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.drawing import gizmos as gizmo +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator +if TYPE_CHECKING: + from bonsai.bim.module.model.prop import BIMWallProperties + + +def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None: + """Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC. + + The preview omits openings, layer materials, and connection joins; those are + resolved on commit by ``recreate_wall`` / ``recalculate_walls``.""" + props = tool.Model.get_wall_props(obj) + length = max(props.length, 0.001) + height = max(props.height, 0.001) + thickness = max(props.thickness, 0.001) + offset = props.offset + x_angle = props.x_angle + x0 = props.anchor_x + x1 = x0 + length + y0 = offset + y1 = offset + thickness + # Slope shifts the top face along +Y by height * tan(x_angle), keeping the bottom fixed. + y_top_shift = core.displacement_from_x_angle(height, x_angle) if x_angle else 0.0 + + bm = bmesh.new() + verts = [ + bm.verts.new((x0, y0, 0.0)), + bm.verts.new((x1, y0, 0.0)), + bm.verts.new((x1, y1, 0.0)), + bm.verts.new((x0, y1, 0.0)), + bm.verts.new((x0, y0 + y_top_shift, height)), + bm.verts.new((x1, y0 + y_top_shift, height)), + bm.verts.new((x1, y1 + y_top_shift, height)), + bm.verts.new((x0, y1 + y_top_shift, height)), + ] + bm.faces.new([verts[0], verts[1], verts[2], verts[3]]) + bm.faces.new([verts[7], verts[6], verts[5], verts[4]]) + bm.faces.new([verts[0], verts[4], verts[5], verts[1]]) + bm.faces.new([verts[3], verts[2], verts[6], verts[7]]) + bm.faces.new([verts[0], verts[3], verts[7], verts[4]]) + bm.faces.new([verts[1], verts[5], verts[6], verts[2]]) + + assert isinstance(obj.data, bpy.types.Mesh) + bm.to_mesh(obj.data) + bm.free() + obj.data.update() + # Mark the mesh as having diverged from the IFC-derived geometry. cancel / + # no-op-finish reads this and calls recreate_wall to restore openings & layers. + tool.Model.get_wall_props(obj).mesh_dirty = True + + +def _restore_wall_mesh_if_dirty(obj: bpy.types.Object) -> None: + """Re-derive the wall mesh from IFC if the bmesh preview replaced the real geometry. + + Idempotent: clears the dirty flag after restoring. Does call into + ``ifcopenshell.api.geometry.regenerate_wall_representation`` (one ifc.run), which is + acceptable here because cancel / no-op-finish are explicit user actions, not per-frame + events. Skipping the call when no drag happened preserves the byte-identical guarantee + for the common enable → ✓ no-drag round-trip.""" + props = tool.Model.get_wall_props(obj) + if not props.mesh_dirty: + return + element = tool.Ifc.get_entity(obj) + if element: + tool.Model.recreate_wall(element, obj) + props.mesh_dirty = False + + +def _validate_wall_for_parametric_edit(obj: bpy.types.Object) -> str | None: + """Return ``None`` if the wall is parametrically editable, else a user-facing reason + string explaining what's missing. Reports the *specific* gap rather than a generic + 'not parametric' so the user knows whether to fix the material layer set, swap the + body representation, or pick a different object.""" + element = tool.Ifc.get_entity(obj) + if not element: + return "Object is not an IFC element." + if not element.is_a("IfcWall"): + return f"Object is an {element.is_a()}, not an IfcWall." + if tool.Model.get_usage_type(element) != "LAYER2": + return "Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)." + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not representation: + return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions." + if not tool.Model.get_extrusion(representation): + return ( + "Wall body is not an IfcExtrudedAreaSolid " "(e.g. a brep mesh or boolean result without a base extrusion)." + ) + return None + + +def _read_wall_state_into_props(obj: bpy.types.Object, props: "BIMWallProperties") -> None: + """Populate the draft props from current IFC state. Caller must have validated the + wall via ``_validate_wall_for_parametric_edit`` first — this function assumes the + wall has a LAYER2 usage and an extruded MODEL_VIEW body.""" + geom = _read_wall_geometry(obj) + assert geom + + props.anchor_x = geom["anchor_x"] + props.length = max(0.01, geom["length"]) + props.height = max(0.01, geom["height"]) + props.x_angle = geom["x_angle"] + props.thickness = max(0.001, geom["thickness"]) + props.offset = geom["offset"] + props.desired_offset_baseline = core.baseline_from_offset(props.offset, props.thickness) + + props.snap_length = props.length + props.snap_height = props.height + props.snap_thickness = props.thickness + props.snap_offset = props.offset + props.snap_x_angle = props.x_angle + props.snap_offset_baseline = props.desired_offset_baseline + class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unjoin_walls" @@ -64,6 +179,7 @@ class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): return True def _execute(self, context): + _commit_pending_wall_edits_for_selection(context) core.unjoin_walls(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) @@ -73,7 +189,18 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Extend and clip selected walls at the bottom faces of an object" bl_options = {"REGISTER", "UNDO"} + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + def _execute(self, context): + # Match the sibling ops (UnjoinWalls / MergeWall / ExtendWallsToWall): if any + # of the selected walls has an in-progress parametric draft, commit it before + # extending, so the slab clip operates on the just-finalised IFC state. + _commit_pending_wall_edits_for_selection(context) slab = None walls: list[bpy.types.Object] = [] if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)): @@ -94,6 +221,7 @@ class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): + _commit_pending_wall_edits_for_selection(context) target_obj = None objs = [] if ( @@ -321,6 +449,7 @@ class SplitWall(bpy.types.Operator, tool.Ifc.Operator): return True def _execute(self, context): + _commit_pending_wall_edits_for_selection(context) selected_objs = tool.Model.get_selected_mesh_objects() for obj in selected_objs: DumbWallJoiner().split(obj, context.scene.cursor.location) @@ -348,6 +477,7 @@ class MergeWall(bpy.types.Operator, tool.Ifc.Operator): return True def _execute(self, context): + _commit_pending_wall_edits_for_selection(context) active_obj = context.active_object assert active_obj selected_objs = tool.Model.get_selected_mesh_objects() @@ -457,7 +587,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle if tool.Model.get_usage_type(element) == "LAYER2": x, y, z = extrusion.ExtrudedDirection.DirectionRatios - depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) + depth = core.vertical_height_from_extrusion_depth(extrusion.Depth, existing_x_angle) perpendicular_depth = depth * abs(1 / cos(x_angle)) extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) layer2_objs.append(obj) @@ -1343,7 +1473,9 @@ class DumbWallJoiner: results["direction"] = Vector(item.ExtrudedDirection.DirectionRatios) results["x_angle"] = Vector((0, 1)).angle_signed(Vector((y, z))) results["is_sloped"] = True - results["height"] = (item.Depth * self.unit_scale) / abs(1 / cos(results["x_angle"])) + results["height"] = core.vertical_height_from_extrusion_depth( + item.Depth * self.unit_scale, results["x_angle"] + ) break elif item.is_a("IfcBooleanClippingResult"): # should be before IfcBooleanResult check item = item.FirstOperand @@ -1408,3 +1540,1072 @@ class DumbWallJoiner: ) return (i_top - i_bottom).length + + +class EnableEditingWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.enable_editing_wall" + bl_label = "Edit Wall" + bl_description = "Show wall edit gizmos" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + reason = _validate_wall_for_parametric_edit(obj) + if reason: + self.report({"WARNING"}, f"Cannot edit wall parametrically: {reason}") + return {"CANCELLED"} + # If openings are currently shown for editing (via the Toggle Openings gizmo + # or the Alt+O hotkey), apply them before entering wall edit mode. Otherwise + # the wall enters edit mode with floating opening previews that don't reflect + # the IFC state the gizmos read from. + if tool.Model.get_model_props().openings: + bpy.ops.bim.edit_openings(apply_all=True) + props = tool.Model.get_wall_props(obj) + # Force is_editing False before populating so update_wall stays a no-op + # while we copy IFC state into the draft properties. + props.is_editing = False + _read_wall_state_into_props(obj, props) + # Mesh stays as the existing IFC-derived geometry until the first gizmo drag + # — that way an enable → ✓ round-trip with no drag is a true no-op. + props.mesh_dirty = False + props.is_editing = True + return {"FINISHED"} + + +class CancelEditingWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.cancel_editing_wall" + bl_label = "Discard Wall Edits" + bl_description = "Discard wall edits" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_wall_props(obj) + # Disable update_wall first so the snap restores don't redraw the preview. + props.is_editing = False + props.length = props.snap_length + props.height = props.snap_height + props.thickness = props.snap_thickness + props.offset = props.snap_offset + # If the user dragged before cancelling, the visible mesh is the simplified + # preview box (openings/layers stripped). Restore the real IFC-derived geometry + # so cancel feels like a true undo — equivalent to the user hitting S_G manually. + _restore_wall_mesh_if_dirty(obj) + return {"FINISHED"} + + +class FinishEditingWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.finish_editing_wall" + bl_label = "Apply Wall Edits" + bl_description = "Apply wall edits" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + element = tool.Ifc.get_entity(obj) + if not element: + return {"CANCELLED"} + props = tool.Model.get_wall_props(obj) + + length_changed = not tool.Cad.is_x(props.length, props.snap_length, tolerance=1e-5) + height_changed = not tool.Cad.is_x(props.height, props.snap_height, tolerance=1e-5) + x_angle_changed = not tool.Cad.is_x(props.x_angle, props.snap_x_angle, tolerance=1e-5) + baseline_changed = props.desired_offset_baseline != props.snap_offset_baseline + any_change = length_changed or height_changed or x_angle_changed or baseline_changed + + # Order matters: baseline shifts the layer-set reference line, then length + # adjusts endpoints relative to that, then x_angle changes the slope (and + # recomputes extrusion direction), and height is applied LAST so it reads the + # final x_angle when converting vertical-height ↔ extrusion-depth. Running + # height before x_angle made the slope op overwrite the just-set height. + # temp_override scopes each sub-op to this wall so the delegated operators + # don't fan out to other selected walls. + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + if baseline_changed: + tool.Model.offset_wall(obj, props.desired_offset_baseline) + tool.Model.recalculate_walls([obj]) + tool.Model.get_model_props().offset_type_vertical = props.desired_offset_baseline + if length_changed: + DumbWallJoiner().set_length(obj, props.length) + tool.Model.recalculate_walls([obj]) + if x_angle_changed: + bpy.ops.bim.change_extrusion_x_angle(x_angle=props.x_angle) + if height_changed: + bpy.ops.bim.change_extrusion_depth(depth=props.height) + + if any_change: + props.mesh_dirty = False + else: + _restore_wall_mesh_if_dirty(obj) + # Set only on success: if any sub-op above raised, the draft survives for retry. + props.is_editing = False + return {"FINISHED"} + + +class CycleWallOffset(bpy.types.Operator): + bl_idname = "bim.cycle_wall_offset" + bl_label = "Cycle Wall Baseline" + bl_description = "Cycle wall baseline through Exterior, Centreline, Interior. Shift+click reverses" + bl_options = {"REGISTER", "UNDO"} + # Deliberately NOT a tool.Ifc.Operator: this operator never calls into + # ifcopenshell.api. Inheriting from Ifc.Operator would drag a draft-only + # property cycle into Bonsai's IFC undo transaction system. + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + # Same order the offset_type_vertical EnumProperty uses in prop.py. + _ORDER = ("EXTERIOR", "CENTER", "INTERIOR") + reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + self.reverse = event.shift + return self.execute(context) + + def execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_wall_props(obj) + if not props.is_editing: + self.report({"WARNING"}, "Cycle wall offset only works in wall edit mode.") + return {"CANCELLED"} + current = props.desired_offset_baseline + idx = self._ORDER.index(current) if current in self._ORDER else 0 + direction = -1 if self.reverse else 1 + props.desired_offset_baseline = self._ORDER[(idx + direction) % len(self._ORDER)] + return {"FINISHED"} + + +class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + bl_idname = "OBJECT_GGT_bim_wall_edition" + bl_label = "Wall Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_wall" + finish_editing_operator = "bim.finish_editing_wall" + cancel_editing_operator = "bim.cancel_editing_wall" + # Empty disables the base class's auto-created cycle_gizmo at ICON_CYCLE_X. + # We render three state-specific baseline icons at that slot instead — see + # ``setup_element_specific_gizmos`` / ``_update_icon_row_extras``. + cycle_type_operator = "" + + # Threshold (SI meters) above which a second height gizmo is drawn at the far end of + # the wall so the user doesn't have to pan across long walls to reach a height handle. + LONG_WALL_THRESHOLD = 5.0 + + dimension_gizmo_props = [ + # length / height / height_end positions are recomputed per frame in + # ``_update_dimension_gizmo_positions`` so they flip to the camera-facing + # side of the wall as the viewport is orbited. No static ``matrix_position`` + # here means the base class falls back to Identity, which the override + # then replaces with the view-dependent coordinates. + DimensionGizmoConfig( + attr_name="length", + axis=(1, 0, 0), + min_value=0.01, + text_offset_sign=-1, + ), + DimensionGizmoConfig( + attr_name="height", + axis=(0, 0, 1), + min_value=0.01, + ), + # Second height gizmo at the far end of long walls. Distinct attr_name so it + # doesn't collide with the first height gizmo in self.dimension_*_gizmo storage; + # compute/apply tunnel through to the same props.height. + DimensionGizmoConfig( + attr_name="height_end", + axis=(0, 0, 1), + min_value=0.01, + # default-arg captures the class const because lambda body can't see class scope. + visibility_condition=lambda p, _t=LONG_WALL_THRESHOLD: p.length > _t, + compute_value=lambda p: p.height, + apply_value=lambda p, v: setattr(p, "height", max(0.01, v)), + color="BLUE", + ), + # Slope: a Y-axis dimension at the top edge measuring horizontal displacement + # of the top face. compute/apply translate between displacement (what the user + # sees & drags) and x_angle (what's stored). Drag toward +Y → positive slope. + DimensionGizmoConfig( + attr_name="x_angle", + axis=(0, 1, 0), + prop_name="Slope", + matrix_position=lambda p: Vector((p.anchor_x + p.length / 2, p.offset + p.thickness / 2, p.height)), + compute_value=lambda p: core.displacement_from_x_angle(p.height, p.x_angle), + apply_value=lambda p, displacement: setattr( + p, "x_angle", core.x_angle_from_displacement(p.height, displacement) + ), + color="GREEN", + min_value=-1e6, # apply_value clamps via atan2; allow negative displacement + text_formatter=lambda p, displacement: ( + f"{'-' if displacement < 0 else ''}{tool.Unit.format_distance(abs(displacement))} " + f"({math.degrees(p.x_angle):.1f}°)" + ), + ), + ] + + props_getter = "get_wall_props" + gizmo_pref_name = "wall" + + @classmethod + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: + return tool.Blender.Modifier.is_wall(element) + + def get_icon_y_extent(self, props: "BIMWallProperties") -> tuple[float, float]: + far = props.offset + props.thickness + 2 * self.GIZMO_OFFSET + near = -props.offset + 2 * self.GIZMO_OFFSET + return (far, near) + + def _update_dimension_gizmo_positions( + self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties" # noqa: ARG002 + ) -> None: + """Re-position length / height / height_end dimensions to the camera-facing + Y-side of the wall every frame. Mirrors the door & stair pattern: when the + viewport is orbited past the wall, the handles jump to the visible face + instead of being stranded behind it. + + - When viewing from -Y: place handles at wall-local Y = ``offset - GIZMO_OFFSET``. + - When viewing from +Y: place handles at wall-local Y = ``offset + thickness + GIZMO_OFFSET``. + + Slope (``x_angle``) is intentionally NOT view-flipped — it lives at the wall + axis centerline because the gizmo IS the Y-displacement indicator. Flipping + it would invert the drag direction relative to the user's pointer motion.""" + viewing_from_neg_y, _ = self._frame_view_dir + y_camera_side = self.get_camera_facing_outer_y( + viewing_from_neg_y, + props.offset, + props.offset + props.thickness, + self.GIZMO_OFFSET, + ) + # Length: along X axis at half-height, on the camera-facing edge. + self.set_dimension_gizmo_position( + "length", + mw, + Vector((props.anchor_x, y_camera_side, props.height / 2)), + (1, 0, 0), + ) + # Height (start of wall): along Z, at the start endpoint, camera-facing side. + self.set_dimension_gizmo_position( + "height", + mw, + Vector((props.anchor_x, y_camera_side, 0)), + (0, 0, 1), + ) + # Height (far end of long walls): along Z, at the end endpoint, camera-facing side. + self.set_dimension_gizmo_position( + "height_end", + mw, + Vector((props.anchor_x + props.length, y_camera_side, 0)), + (0, 0, 1), + ) + + # X offsets in the editing icon row, additive from ICON_VALIDATE_X (0.0). + # Matches the cadence used by the base class (0.0 / 0.5 / 0.87 = step ≈ 0.37). + # The baseline icons (EXT / CEN / INT) all share ICON_CYCLE_X — only one is + # ever visible at a time so they don't overlap. + ICON_ROTATE_X = 1.24 + + # Mapping from BIMWallProperties.desired_offset_baseline value to the + # attribute on `self` that holds the corresponding state icon. + _BASELINE_GIZMO_ATTRS: ClassVar[dict[str, str]] = { + "EXTERIOR": "offset_exterior_gizmo", + "CENTER": "offset_center_gizmo", + "INTERIOR": "offset_interior_gizmo", + } + + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Wall-specific gizmos. + + Cursor-anchored (always visible during edit mode, conditional position): + + - ``split_gizmo`` — at the 3D cursor's exact world position when cursor is + within the wall's X range. Clicking splits the wall there. + - ``extend_x_gizmo`` — at the wall-local X of the cursor, projected to the + floor plane (Z=0 in wall-local). Clicking extends/trims the wall's length. + - ``extend_z_gizmo`` — at the wall-local X of the cursor, projected to the + wall top (Z=height in wall-local). Clicking extends the wall's height to + the cursor's Z. + + Icon-row (always visible during edit mode, fixed position): + + - ``offset_{exterior,center,interior}_gizmo`` — three state-specific icons, + only one visible at a time. Reflects ``props.desired_offset_baseline``. + Clicking any of them cycles the baseline (the operator is the same). + - ``rotate_gizmo`` — rotates the wall 90° around Z (Shift+R). Uses the + revolving-arrows icon now that the cycle slot is occupied by the + stateful baseline icons. + - ``toggle_openings_gizmo`` — toggles opening fill visibility (Alt+O). + """ + default_color, highlight_color = self.get_decoration_colors() + self.split_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_split", + default_color, + "bim.split_wall_at_cursor", + highlight_color, + ) + self.extend_x_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_extend", + default_color, + "bim.extend_wall_to_cursor", + highlight_color, + ) + self.extend_z_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_extend_vertical", + default_color, + "bim.extend_wall_height_to_cursor", + highlight_color, + ) + # Three baseline-state icons — only one is visible at a time, picked by + # the current props.desired_offset_baseline. All point to the same cycle + # operator so clicking any of them advances the cycle. + for baseline, attr_name in self._BASELINE_GIZMO_ATTRS.items(): + setattr( + self, + attr_name, + self._setup_icon_gizmo( + f"VIEW3D_GT_offset_{baseline.lower()}", + default_color, + "bim.cycle_wall_offset", + highlight_color, + ), + ) + self.rotate_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_cycle", + default_color, + "bim.rotate_wall_90", + highlight_color, + ) + self.toggle_openings_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_add_opening", + default_color, + "bim.toggle_wall_openings", + highlight_color, + ) + + def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None: + """Position cursor-anchored gizmos and the wall-specific icon-row extras.""" + self._update_cursor_gizmos(context, mw, props) + self._update_icon_row_extras(context, mw, props) + + # World-Z spacing between stacked cursor icons. ~0.3m is ~1.5× icon diameter + # at default scale, leaving a small visual gap between consecutive icons. + CURSOR_STACK_OFFSET = 0.3 + + def _update_cursor_gizmos(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None: + """Position the cursor-anchored icons (extend-X / extend-Z / split) on the wall + axis at the cursor's projected X, each at the Z its action would land at. + + When two icons want the same Z (within ``CURSOR_STACK_OFFSET``), bump the + lower-priority one upward so both stay clickable. Priority low → high: + extend-X, extend-Z, split. Bumps cascade — bumping extend-Z up can in turn + collide with split, so extend-Z gets bumped further to clear it.""" + if not hasattr(self, "split_gizmo"): + return + gizmo_prefs = self.get_gizmo_prefs() + all_gizmos = (self.extend_x_gizmo, self.extend_z_gizmo, self.split_gizmo) + if not props.is_editing: + for gz in all_gizmos: + gz.hide = True + return + cursor_world = context.scene.cursor.location + cursor_local = mw.inverted() @ cursor_world + in_range = props.anchor_x < cursor_local.x < props.anchor_x + props.length + billboard_rot = self._frame_billboard_rot + + # Candidates ordered by priority (lowest first). Each is (gizmo, local_z). + # The local X and Y are common: at the cursor's projected X on the axis. + # Only "active" gizmos (enabled + applicable) participate in placement. + candidates: list[tuple[bpy.types.Gizmo, float]] = [] + if gizmo_prefs.extend: + candidates.append((self.extend_x_gizmo, 0.0)) + if gizmo_prefs.extend_height: + candidates.append((self.extend_z_gizmo, cursor_local.z)) + if in_range and gizmo_prefs.scissors: + candidates.append((self.split_gizmo, props.height)) + + # Resolve collisions: walk in priority order and ensure each gizmo's + # final Z is at least CURSOR_STACK_OFFSET above the previous one (when + # the previous one's final Z is higher). + resolved: list[tuple[bpy.types.Gizmo, float]] = [] + for gz, desired_z in candidates: + final_z = desired_z + for _, prev_z in resolved: + if abs(final_z - prev_z) < self.CURSOR_STACK_OFFSET: + # Bump up to clear the previous gizmo's slot. + final_z = prev_z + self.CURSOR_STACK_OFFSET + resolved.append((gz, final_z)) + + for gz in all_gizmos: + gz.hide = True + for gz, local_z in resolved: + gz.hide = self.is_gizmo_hidden_by_modal(gz) + world_pos = mw @ Vector((cursor_local.x, 0.0, local_z)) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) + + def _update_icon_row_extras(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None: + """Position the wall-specific icons in the icon row. + + Edit-mode icons (visible only when ``props.is_editing``): + + - Three baseline icons (Exterior / Centreline / Interior) share the cycle + slot — only the one matching ``props.desired_offset_baseline`` shows. + - Rotate-90 icon at ``ICON_ROTATE_X``. + + Non-edit-mode icons (visible alongside the pen icon, hidden during edit): + + - Toggle-openings icon next to the pen. Lives outside edit mode because + opening visibility is a viewport-display concern, not a wall-edit action. + + Calls ``billboarded_at`` directly rather than routing through + ``set_icon_gizmo_position`` because the icon row has wall-specific + visibility/state branching (baseline-indicator selection, edit-mode + toggle for opening-visibility) that the helper does not model.""" + if not hasattr(self, "rotate_gizmo"): + return + gizmo_prefs = self.get_gizmo_prefs() + icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET + icon_y = self.get_icon_y_offset(context, mw) + billboard_rot = self._frame_billboard_rot + + # --- Edit-mode icons (baseline indicator + rotate-90) --- + if props.is_editing: + # Stateful baseline indicator at the cycle slot. Show exactly one of the + # three icons (the one matching the current baseline), hide the others. + for baseline, attr in self._BASELINE_GIZMO_ATTRS.items(): + gz = getattr(self, attr) + if gizmo_prefs.cycle and baseline == props.desired_offset_baseline: + gz.hide = self.is_gizmo_hidden_by_modal(gz) + world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z)) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) + else: + gz.hide = True + if gizmo_prefs.rotate: + self.rotate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.rotate_gizmo) + world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_ROTATE_X, icon_y, icon_z)) + # VIEW3D_GT_cycle is authored for the base class's 0.30 scale; at 0.5 + # it looks roughly 2x too big next to the validate / cancel icons. + self.rotate_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=0.30) + else: + self.rotate_gizmo.hide = True + else: + for attr in self._BASELINE_GIZMO_ATTRS.values(): + getattr(self, attr).hide = True + self.rotate_gizmo.hide = True + + # --- Non-edit-mode icons (toggle openings) --- + # Sits at the slot the cancel icon occupies during editing — that way the + # pen + openings pair is compact and visually grouped. + if not props.is_editing and gizmo_prefs.toggle_openings: + self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo) + world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z)) + self.toggle_openings_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) + else: + self.toggle_openings_gizmo.hide = True + + +def _commit_active_wall_edit_if_any(context: bpy.types.Context) -> bpy.types.Object | None: + """Return the active object, committing any in-progress wall edit first. + + Used by the scissors/extend gizmo operators: clicking either icon implicitly + validates the current edit (✓ semantics) before running the follow-up action. + Returns None when there's no active object — callers should treat that as CANCELLED.""" + obj = context.active_object + if not obj: + return None + props = tool.Model.get_wall_props(obj) + if props.is_editing: + bpy.ops.bim.finish_editing_wall() + return obj + + +def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001 + """Thin wall-scoped alias for `tool.Parametric.commit_pending_edits_for_selection`. + + Kept as a named helper because every multi-wall operator (split / join / merge / + unjoin / extend-to-wall …) calls it at the top of ``_execute``; centralising the + ``names=("wall",)`` filter here means the registry name is touched in one place.""" + tool.Parametric.commit_pending_edits_for_selection(names=("wall",)) + + +class SplitWallAtCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.split_wall_at_cursor" + bl_label = "Split Wall at Cursor" + bl_description = "Split wall at 3D cursor location" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context: bpy.types.Context) -> set[str]: + # Applies any pending wall edit first so the split operates on the committed + # geometry rather than the draft preview box. + if _commit_active_wall_edit_if_any(context) is None: + return {"CANCELLED"} + bpy.ops.bim.split_wall() + return {"FINISHED"} + + +class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_wall_to_cursor" + bl_label = "Extend Wall to Cursor" + bl_description = "Extend wall length to 3D cursor location" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context: bpy.types.Context) -> set[str]: + if _commit_active_wall_edit_if_any(context) is None: + return {"CANCELLED"} + core.extend_walls( + tool.Ifc, + tool.Blender, + tool.Geometry, + DumbWallJoiner(), + tool.Model, + context.scene.cursor.location, + ) + return {"FINISHED"} + + +class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_wall_height_to_cursor" + bl_label = "Extend Wall Height to Cursor Z" + bl_description = "Extend wall height to 3D cursor Z location" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = _commit_active_wall_edit_if_any(context) + if obj is None: + return {"CANCELLED"} + cursor_z = context.scene.cursor.location.z + base_z = obj.matrix_world.translation.z + new_height = cursor_z - base_z + if new_height <= 0: + self.report( + {"WARNING"}, + f"Cursor Z ({cursor_z:.2f}m) must be above wall base ({base_z:.2f}m).", + ) + return {"CANCELLED"} + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + bpy.ops.bim.change_extrusion_depth(depth=new_height) + return {"FINISHED"} + + +class RotateWall90(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.rotate_wall_90" + bl_label = "Rotate Wall 90°" + bl_description = "Rotate wall 90° around Z axis" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = _commit_active_wall_edit_if_any(context) + if obj is None: + return {"CANCELLED"} + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + bpy.ops.bim.rotate_90(axis="Z") + return {"FINISHED"} + + +class ToggleWallOpenings(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.toggle_wall_openings" + bl_label = "Toggle Openings" + bl_description = "Show or hide opening fills (doors and windows) in the viewport" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context: bpy.types.Context) -> set[str]: + # Opening visibility is independent of wall geometry — don't commit the + # active wall edit; the user can keep editing the wall. + if tool.Model.get_model_props().openings: + bpy.ops.bim.edit_openings(apply_all=True) + else: + bpy.ops.bim.show_openings() + return {"FINISHED"} + + +def _read_wall_geometry(obj: bpy.types.Object) -> dict | None: + """Live-read wall geometry from IFC. Returns ``None`` if the wall is not a LAYER2 extruded wall.""" + element = tool.Ifc.get_entity(obj) + if not element or not tool.Blender.Modifier.is_wall(element): + return None + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(element) + layer_params = tool.Model.get_material_layer_parameters(element) + x_angle = tool.Model.get_existing_x_angle(extrusion) + return { + "anchor_x": p1[0] * unit_scale, + "length": (p2[0] - p1[0]) * unit_scale, + "height": core.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle), + "x_angle": x_angle, + "thickness": layer_params["thickness"], + "offset": layer_params["offset"], + } + + +def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]: + """Compose the world-space axis segment from an already-read ``geom`` dict. + Used by the billboarding gizmo groups so a single cached IFC read drives both + ``_read_wall_geometry`` *and* the segment, avoiding two reads per wall per frame.""" + p1_local = Vector((geom["anchor_x"], 0.0, 0.0)) + p2_local = Vector((geom["anchor_x"] + geom["length"], 0.0, 0.0)) + return obj.matrix_world @ p1_local, obj.matrix_world @ p2_local + + +class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin): + """Adds IFC-read caching to `BillboardingGizmoGroupMixin` for wall-driven + gizmo groups. ``refresh()`` is Blender's "something state-relevant changed" + signal — that's when we drop the cache. ``draw_prepare()`` (every redraw) reuses + whatever ``_get_wall_geom_cached`` populated, so plain camera orbits don't re-hit + IFC. ``_get_wall_geom_cached`` also drops entries on its own when + `tool.Parametric.get_geom_generation` advances (any ``tool.Ifc.Operator`` + commit) so external ``bpy.ops`` mutations on the same selection don't leave + stale geometry behind.""" + + def refresh(self, context: bpy.types.Context) -> None: + self._wall_geom_cache = None + self.position_gizmos(context) + + +def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) -> dict | None: + """Per-gizmo-group memoised ``_read_wall_geometry``. Without this, a + billboarding gizmo group re-runs the IFC read on every camera orbit frame — + ~120 IFC queries per second per wall, which is unwieldy on dense models. + + Two invalidation paths: + + - ``GizmoGroup.refresh()`` (Blender's state-change hook — selection, + gizmo modal exit, …) clears ``_wall_geom_cache`` directly. + - ``tool.Parametric.refresh_post_commit()`` bumps a generation counter on + every IFC operator commit; the cache stores the generation it was filled + at and drops on mismatch. This catches ``bpy.ops.bim.*`` mutations that + edit the wall while the same selection is held (the case Blender's + ``refresh()`` doesn't fire on).""" + current_gen = tool.Parametric.get_geom_generation() + cache_gen = getattr(group, "_wall_geom_cache_gen", None) + cache = getattr(group, "_wall_geom_cache", None) + if cache is None or cache_gen != current_gen: + cache = {} + group._wall_geom_cache = cache + group._wall_geom_cache_gen = current_gen + key = obj.name + if key not in cache: + cache[key] = _read_wall_geometry(obj) + return cache[key] + + +def _wall_camera_facing_icon_y(context: bpy.types.Context, mw: Matrix, geom: dict) -> float: + """Wall-local Y for an icon that should sit just outside the camera-facing face. + Centralised so the billboarding wall gizmos (add-opening, extend-vertically, …) + share one source of truth for "where does the icon go on the visible side".""" + viewing_from_negative_y, _ = gizmo.BaseParametricGizmoGroup.get_local_view_direction(context, mw) + return gizmo.BaseParametricGizmoGroup.get_camera_facing_outer_y( + viewing_from_negative_y, + geom["offset"], + geom["offset"] + geom["thickness"], + gizmo.BaseParametricGizmoGroup.GIZMO_OFFSET, + ) + + +def _are_walls_joined(elem_a: ifcopenshell.entity_instance, elem_b: ifcopenshell.entity_instance) -> bool: + """True if there's an ``IfcRelConnectsPathElements`` relating these two walls. + + Bonsai's wall joiner creates ``IfcRelConnectsPathElements`` (a specialization of + ``IfcRelConnectsElements``) whenever walls share a corner or mitre. We walk both + inverse arrays of the first wall and look for the second wall on the other side + of any path-element rel.""" + for rel in getattr(elem_a, "ConnectedTo", []): + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_b: + return True + for rel in getattr(elem_a, "ConnectedFrom", []): + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_b: + return True + return False + + +def _are_walls_collinear( + seg_a: tuple[Vector, Vector], + seg_b: tuple[Vector, Vector], + parallel_threshold: float = 0.9994, + line_tolerance: float = 0.05, +) -> bool: + """Vector wrapper around `core.are_axes_collinear` — converts Vector + endpoints to plain tuples at the boundary so the math stays unit-testable in + ``test/core/`` without a mathutils dependency.""" + return core.are_axes_collinear( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + parallel_threshold, + line_tolerance, + ) + + +def _collinear_boundary_world(seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector: + """Vector wrapper around `core.closest_endpoint_midpoint`.""" + return Vector( + core.closest_endpoint_midpoint( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + ) + ) + + +class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Activates when a wall (active) and one non-wall blender object are co-selected. + + Renders a single icon above the wall at the wall-local X corresponding to the other + object's projected origin. Clicking dispatches `bim.add_opening`, which lets the + existing FilledOpeningGenerator decide how the opening is applied. + + Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icon + keeps facing the camera as the viewport is orbited.""" + + bl_idname = "OBJECT_GGT_bim_wall_add_opening" + bl_label = "Wall Add Opening Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + prefs = tool.Blender.get_addon_preferences() + if not prefs.gizmos.draw_gizmos_in_3d_viewport: + return False + selected = tool.Blender.get_selected_objects() + if len(selected) != 2: + return False + active = context.active_object + 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): + 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): + return False + return True + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorations_colour[:3] + highlight_color = prefs.decorator_color_selected[:3] + self.add_opening_icon = self.setup_icon_gizmo( + "VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening" + ) + + def position_gizmos(self, context: bpy.types.Context) -> None: + wall_obj = context.active_object + if not wall_obj: + return + selected = tool.Blender.get_selected_objects() + other = next((o for o in selected if o is not wall_obj), None) + if not other: + return + geom = _get_wall_geom_cached(self, wall_obj) + if not geom: + return + mw = wall_obj.matrix_world + wall_local = mw.inverted() @ other.matrix_world.translation + local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"])) + # Place the icon on the camera-facing side of the wall, like the pen icon + # does for parametric edits — orbit the camera past the wall and the icon + # jumps to the visible face instead of being stranded behind it. + icon_y = _wall_camera_facing_icon_y(context, mw, geom) + icon_z = geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET + world_pos = mw @ Vector((local_x, icon_y, icon_z)) + self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context)) + + +class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Activates when a LAYER3 element (typically a slab) is active and a LAYER2 + wall is co-selected. Mirrors the N-panel ``Extend To Underside`` button (which + shows under the same active-LAYER3 + LAYER2-in-selection rule). Clicking + dispatches ``bim.extend_walls_to_underside``, which extends the wall up to the + active element's bottom faces. + + Anchored at the wall's local X = 0 (wall origin endpoint), wall-local Y on the + camera-facing side, and the world Z of the active object — so the icon visually + sits at the elevation the wall will reach after extending.""" + + bl_idname = "OBJECT_GGT_bim_wall_extend_vertically" + bl_label = "Wall Extend Vertically Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + prefs = tool.Blender.get_addon_preferences() + if not prefs.gizmos.draw_gizmos_in_3d_viewport: + return False + selected = tool.Blender.get_selected_objects() + if len(selected) != 2: + return False + active = context.active_object + if active is None or active not in selected: + return False + active_element = tool.Ifc.get_entity(active) + if not active_element or tool.Model.get_usage_type(active_element) != "LAYER3": + return False + other = next(o for o in selected if o is not active) + other_element = tool.Ifc.get_entity(other) + if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2": + return False + return True + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorations_colour[:3] + highlight_color = prefs.decorator_color_selected[:3] + self.extend_vertical_icon = self.setup_icon_gizmo( + "VIEW3D_GT_extend_vertical", + default_color, + highlight_color, + "bim.extend_walls_to_underside", + ) + + def position_gizmos(self, context: bpy.types.Context) -> None: + active = context.active_object + if active is None: + return + wall_obj = next((o for o in tool.Blender.get_selected_objects() if o is not active), None) + if wall_obj is None: + return + geom = _get_wall_geom_cached(self, wall_obj) + if not geom: + return + mw = wall_obj.matrix_world + icon_y = _wall_camera_facing_icon_y(context, mw, geom) + # X = 0 in wall-local, Y on the camera-facing outer side, world Z lifted to + # the active object's elevation — the height the wall is about to reach. + world_pos = mw @ Vector((0.0, icon_y, 0.0)) + world_pos.z = active.matrix_world.translation.z + self.extend_vertical_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context)) + + +class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Activates when exactly two LAYER2 walls are selected. Dispatches between four + state-specific icons based on the geometric + IFC relationship of the walls: + + - **Joined** (``IfcRelConnectsPathElements`` between them): + ``unjoin_icon`` (``VIEW3D_GT_split``, outward arrows) at the shared corner. + Clicking dispatches ``bim.unjoin_walls``. + - **Collinear** (axes on the same infinite line, not joined): + ``merge_icon`` (``VIEW3D_GT_merge``, inward arrows) at the midpoint of the + closest endpoint pair. Clicking dispatches ``bim.merge_wall``. + - **Joinable corner** (non-parallel, axes meet near endpoints, not joined): + ``join_icon`` (``VIEW3D_GT_merge``) at the projected intersection on the + floor, PLUS ``extend_to_wall_icon`` (``VIEW3D_GT_extend``) at the + intersection at the active wall's Z=height. The Z difference disambiguates + "join the corner" vs "extend this wall into the other." + - **None of the above**: all icons hidden. + + Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icons + keep facing the camera as the viewport is orbited.""" + + bl_idname = "OBJECT_GGT_bim_wall_join_intersection" + bl_label = "Wall Join Intersection Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + # Hide the gizmo when walls are nearly parallel (intersection would be unreasonably far). + # cos(2°) ≈ 0.9994 → walls within ~2° of parallel are treated as parallel for this purpose. + PARALLEL_DOT_THRESHOLD = 0.9994 + # The intersection must be within this many *wall-lengths* of the NEAREST endpoint + # of each wall. This filters out the case where two walls are offset from world + # origin and their extrapolated axes happen to cross at a point that isn't near + # either wall's actual endpoints (which previously caused the icon to land at + # world origin for walls whose axes coincidentally converged there). + MAX_DISTANCE_TO_ENDPOINT_FACTOR = 0.75 + # Perpendicular tolerance (m) for treating two parallel wall axes as collinear. + COLLINEAR_LINE_TOLERANCE = 0.05 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + prefs = tool.Blender.get_addon_preferences() + if not prefs.gizmos.draw_gizmos_in_3d_viewport: + return False + selected = tool.Blender.get_selected_objects() + if len(selected) != 2: + return False + for o in selected: + element = tool.Ifc.get_entity(o) + if not element or not tool.Blender.Modifier.is_wall(element): + return False + return True + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorations_colour[:3] + highlight_color = prefs.decorator_color_selected[:3] + self.unjoin_icon = self.setup_icon_gizmo("VIEW3D_GT_split", default_color, highlight_color, "bim.unjoin_walls") + self.merge_icon = self.setup_icon_gizmo("VIEW3D_GT_merge", default_color, highlight_color, "bim.merge_wall") + self.join_icon = self.setup_icon_gizmo( + "VIEW3D_GT_merge", default_color, highlight_color, "bim.join_walls_intersection" + ) + self.extend_to_wall_icon = self.setup_icon_gizmo( + "VIEW3D_GT_extend", default_color, highlight_color, "bim.extend_walls_to_wall" + ) + + def _all_icons(self) -> tuple[bpy.types.Gizmo, ...]: + return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_icon) + + def _hide_all(self) -> None: + for icon in self._all_icons(): + icon.hide = True + + def position_gizmos(self, context: bpy.types.Context) -> None: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + self._hide_all() + return + elem_a = tool.Ifc.get_entity(selected[0]) + elem_b = tool.Ifc.get_entity(selected[1]) + geom_a = _get_wall_geom_cached(self, selected[0]) + geom_b = _get_wall_geom_cached(self, selected[1]) + if elem_a is None or elem_b is None or geom_a is None or geom_b is None: + self._hide_all() + return + seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a) + seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b) + billboard_rot = gizmo.get_billboard_rotation(context) + + # State 1: walls are already joined → show Unjoin only, at the shared + # corner's floor Z (no visibility lift — user expects the icon to sit + # exactly at the corner, not floating above it). + if _are_walls_joined(elem_a, elem_b): + corner = _collinear_boundary_world(seg_a, seg_b) + self.unjoin_icon.matrix_basis = gizmo.billboarded_at(corner, billboard_rot) + self.unjoin_icon.hide = False + self.merge_icon.hide = True + self.join_icon.hide = True + self.extend_to_wall_icon.hide = True + return + + # State 2: walls are collinear (parallel axes on the same line) → show Merge + # at the boundary midpoint between them, at floor Z (no visibility lift). + if _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE): + boundary = _collinear_boundary_world(seg_a, seg_b) + self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot) + self.merge_icon.hide = False + self.unjoin_icon.hide = True + self.join_icon.hide = True + self.extend_to_wall_icon.hide = True + return + + # State 3: non-parallel walls whose axes meet near each wall's endpoint + # → show Join at the floor + Extend-to-Wall at the active wall's top. + intersection_tuple = core.project_axis_intersection( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + self.PARALLEL_DOT_THRESHOLD, + ) + if intersection_tuple is None: + self._hide_all() + return + intersection = Vector(intersection_tuple) + len_a = (seg_a[1] - seg_a[0]).length + len_b = (seg_b[1] - seg_b[0]).length + near_a = min((intersection - seg_a[0]).length, (intersection - seg_a[1]).length) + near_b = min((intersection - seg_b[0]).length, (intersection - seg_b[1]).length) + if ( + near_a > len_a * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR + or near_b > len_b * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR + ): + self._hide_all() + return + + # Join sits on the floor (lowest endpoint Z across both wall axes), exactly + # where the corner meets the ground — no visibility lift. + floor_z = min(seg_a[0].z, seg_a[1].z, seg_b[0].z, seg_b[1].z) + join_world = Vector((intersection.x, intersection.y, floor_z)) + self.join_icon.matrix_basis = gizmo.billboarded_at(join_world, billboard_rot) + self.join_icon.hide = False + + # Extend-to-Wall sits at the active wall's top, same XY as the join icon — + # the Z gap is what differentiates "join at corner" from "extend into other". + active = context.active_object if context.active_object in selected else None + geom = _read_wall_geometry(active) if active else None + if geom is None: + self.extend_to_wall_icon.hide = True + else: + active_top_z = active.matrix_world.translation.z + geom["height"] + extend_world = Vector((intersection.x, intersection.y, active_top_z)) + self.extend_to_wall_icon.matrix_basis = gizmo.billboarded_at(extend_world, billboard_rot) + self.extend_to_wall_icon.hide = False + + self.unjoin_icon.hide = True + self.merge_icon.hide = True + + +class JoinWallsIntersection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.join_walls_intersection" + bl_label = "Join Walls at Corner" + bl_description = "Join two walls at their corner" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context: bpy.types.Context) -> set[str]: + _commit_pending_wall_edits_for_selection(context) + try: + core.join_walls_LV(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) + except core.RequireTwoWallsError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 30e8d767b5..2432549661 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -39,6 +39,7 @@ 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.parametric_lifecycle import FeatureModifierEditMixin if TYPE_CHECKING: from bonsai.bim.module.model.prop import BIMWindowProperties @@ -482,90 +483,53 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator): +class _WindowEditMixin(FeatureModifierEditMixin): + """Type-specific hooks for window parametric-edit operators. Single-object + by design (window edits target the active object only).""" + + pset_name = "BBIM_Window" + + @classmethod + def _is_element_type(cls, element): + return tool.Blender.Modifier.is_window(element) + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_window_props(obj) + + @classmethod + def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + update_window_modifier_representation(context) + + +class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.cancel_editing_window" bl_label = "Cancel Editing Window" bl_description = "Cancel editing and revert window parameters to their previous values" - bl_options = {"REGISTER"} + bl_options = {"REGISTER", "UNDO"} def _execute(self, context: bpy.types.Context) -> set[str]: - obj = context.active_object - assert obj - element = tool.Ifc.get_entity(obj) - assert element - data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data")) - data.update(data.pop("lining_properties")) - data.update(data.pop("panel_properties")) - props = tool.Model.get_window_props(obj) - props.set_props_kwargs_from_ifc_data(data) - - body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=body, - ) - - props.is_editing = False - return {"FINISHED"} + return self._cancel_targets(context) -class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): +class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.finish_editing_window" bl_label = "Finish Editing Window" bl_description = "Apply changes and finish editing window parameters" - bl_options = {"REGISTER"} + bl_options = {"REGISTER", "UNDO"} def _execute(self, context: bpy.types.Context) -> set[str]: - obj = context.active_object - assert obj - element = tool.Ifc.get_entity(obj) - assert element - props = tool.Model.get_window_props(obj) - - window_data = props.get_general_kwargs(convert_to_project_units=True) - lining_props = props.get_lining_kwargs(convert_to_project_units=True) - panel_props = props.get_panel_kwargs(convert_to_project_units=True) - - window_data["lining_properties"] = lining_props - window_data["panel_properties"] = panel_props - - props.is_editing = False - - update_window_modifier_representation(context) - element_type = ifcopenshell.util.element.get_type(element) - if element_type: - tool.Model.mark_thumbnail_for_update(element_type) - - pset = tool.Pset.get_element_pset(element, "BBIM_Window") - window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list)) - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data}) - return {"FINISHED"} + return self._finish_targets(context) -class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_window" bl_label = "Enable Editing Window" bl_description = "Enter edit mode to modify window parameters interactively" - bl_options = {"REGISTER"} + bl_options = {"REGISTER", "UNDO"} def _execute(self, context: bpy.types.Context) -> set[str]: - obj = context.active_object - assert obj - props = tool.Model.get_window_props(obj) - element = tool.Ifc.get_entity(obj) - assert element - data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data")) - data.update(data.pop("lining_properties")) - data.update(data.pop("panel_properties")) - data.update(tool.Model.get_constituents_props_data(element)) - - # required since we could load pset from .ifc and BIMWindowProperties won't be set - props.set_props_kwargs_from_ifc_data(data) - - props.is_editing = True - return {"FINISHED"} + return self._enable_targets(context) class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index d38c4f3b68..284d427cf3 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import datetime import json @@ -1903,11 +1905,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper): self.use_relative_path = tool.Project.get_project_props().use_relative_project_path props = tool.Blender.get_bim_props() - if (filepath := props.ifc_file) and not self.should_save_as: - self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath))) - return self.execute(context) - - return ExportHelper.invoke(self, context, event) + filepath = props.ifc_file + if not filepath or self.should_save_as: + return ExportHelper.invoke(self, context, event) + self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath))) + return self.execute(context) def check(self, context): # ExportHelper is automatically adjusting suffix to `filename_ext`. @@ -1933,6 +1935,16 @@ class ExportIFC(bpy.types.Operator, ExportHelper): return {"FINISHED"} def _execute(self, context): + committed, failed_commits = tool.Parametric.commit_pending_edits() + # Suffix is appended to the IFC save-success report below so the auto-commit + # info isn't immediately overwritten by the success message in Blender's + # status bar (only the latest self.report({"INFO"}, ...) sticks). + commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else "" + if failed_commits: + names = ", ".join(o.name for o in failed_commits) + msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}" + print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).") + self.report({"ERROR"}, msg) start = time.time() logger = logging.getLogger("ExportIFC") path_log = tool.Blender.get_data_dir_path("process.log") @@ -2001,7 +2013,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): blendmetadata_path = output_file + suffix self.report( {"INFO"}, - f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}', + f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}', ) except Exception as e: self.report({"ERROR"}, f"Failed to save blend metadata file: {e}") @@ -2011,7 +2023,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath) self.report( {"INFO"}, - f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved', + f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved{commit_suffix}', ) bonsai.bim.handler.refresh_ui_data() diff --git a/src/bonsai/bonsai/bim/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py new file mode 100644 index 0000000000..94324afa06 --- /dev/null +++ b/src/bonsai/bonsai/bim/parametric_lifecycle.py @@ -0,0 +1,297 @@ +# 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. + +"""Shared Enable / Finish / Cancel lifecycle mixins for parametric-edit operators. + +Two mixins fit the parametric-edit triads in ``bim/module/model/``: + +`FeatureModifierEditMixin` + Door, Window — BBIM_ pset with nested ``lining_properties`` / + ``panel_properties``; Finish calls ``update__modifier_representation`` + via ``ifcopenshell.api.feature``; Cancel restores via ``switch_representation``. + +`PathPreservingEditMixin` + Railing, Roof — BBIM_ pset whose ``path_data`` is preserved through + edit (only general kwargs are user-editable); Finish calls + ``update__modifier_bmesh`` / ``update__modifier_ifc_data``; + Cancel re-reads the pset and rebuilds the bmesh preview. + +Stair and Wall stay standalone — their lifecycles diverge in ways that don't +fit either mixin without optional escape hatches (Stair has a unique +``update_ifc_stair_props`` post-Finish step + a separate ``get_props_kwargs_for_ifc_export``; +Wall is validation-first, snapshot-driven, no preview regen in operators). + +This module sits separately from `bonsai.tool.Parametric` (the registry + +auto-commit) because it imports ``bonsai.tool`` freely, while the registry +itself must stay light — ``tool/blender.py`` consumes the registry at module load.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, ClassVar + +import bpy +import ifcopenshell.api.pset +import ifcopenshell.util.element +import ifcopenshell.util.representation + +import bonsai.core.geometry +import bonsai.tool as tool + +if TYPE_CHECKING: + from ifcopenshell import entity_instance + + +class _ParametricEditMixinBase: + """Common scaffolding for parametric edit-triad mixins. + + Each per-type subclass provides four hooks: + + ``pset_name``: BBIM_ pset identifier + ``_is_element_type(element)``: IFC element predicate + ``_get_props(obj)``: PropertyGroup accessor + ``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``) + + Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` / + ``_cancel_targets`` from their ``_execute`` method.""" + + pset_name: ClassVar[str] + + @classmethod + def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]: + obj = context.active_object + return [obj] if obj else [] + + @classmethod + def _is_element_type(cls, element: entity_instance) -> bool: + raise NotImplementedError + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + raise NotImplementedError + + @classmethod + def _resolve(cls, obj: bpy.types.Object): + """Look up ``(element, props)`` for ``obj`` if it matches this type, else None. + + Common predicate guard for every lifecycle method — collapses the + ``element = tool.Ifc.get_entity(obj); assert element; if not is_(element): return`` + triplet into one call.""" + element = tool.Ifc.get_entity(obj) + if not element or not cls._is_element_type(element): + return None + return element, cls._get_props(obj) + + +class FeatureModifierEditMixin(_ParametricEditMixinBase): + """Lifecycle for door- and window-style parametric modifier operators. + + Enable: + Read BBIM_ pset JSON → unwrap ``lining_properties`` and + ``panel_properties`` → merge constituents data → set draft props → + ``is_editing = True``. + + Finish: + Gather ``general / lining / panel`` kwargs (project units) → nest → + ``is_editing = False`` → call ``_update_modifier_representation`` → + mark thumbnail → write back to BBIM_ pset via + ``ifcopenshell.api.pset.edit_pset``. + + Cancel: + Read BBIM_ pset JSON → unwrap → restore draft props → + ``switch_representation`` to the Body representation → + ``is_editing = False``.""" + + @classmethod + def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Hook: call the per-type ``update__modifier_representation``. + + Door's helper takes ``obj``; window's takes ``context``. The hook lets + each subclass forward to its existing helper without unifying signatures.""" + raise NotImplementedError + + @classmethod + def _enable_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data")) + data.update(data.pop("lining_properties")) + data.update(data.pop("panel_properties")) + data.update(tool.Model.get_constituents_props_data(element)) + # required since the pset can be loaded from .ifc and the PropertyGroup + # would otherwise still hold its default values + props.set_props_kwargs_from_ifc_data(data) + props.is_editing = True + + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + data = props.get_general_kwargs(convert_to_project_units=True) + data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True) + data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True) + cls._update_modifier_representation(obj, context) + element_type = ifcopenshell.util.element.get_type(element) + if element_type: + tool.Model.mark_thumbnail_for_update(element_type) + pset = tool.Pset.get_element_pset(element, cls.pset_name) + data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list)) + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data_text}) + # Set only on success: if any IFC op above raised, the user's draft survives for retry. + props.is_editing = False + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data")) + data.update(data.pop("lining_properties")) + data.update(data.pop("panel_properties")) + props.set_props_kwargs_from_ifc_data(data) + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body) + props.is_editing = False + + def _enable_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._enable_one(obj) + return {"FINISHED"} + + def _finish_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._finish_one(obj, context) + return {"FINISHED"} + + def _cancel_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._cancel_one(obj) + return {"FINISHED"} + + +class PathPreservingEditMixin(_ParametricEditMixinBase): + """Lifecycle for railing- and roof-style parametric modifier operators. + + Distinctive: ``path_data`` is part of the BBIM_ pset but is **not** + user-editable through this triad — it survives the edit untouched, only + general kwargs are diffed. (Path editing has its own separate operator + pair, ``Enable/Finish/CancelEditingPath``, out of scope here.) + + Enable: + Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` → set + draft props → ``is_editing = True``. The subclass post-load hook + lets railing JSON-serialise ``path_data`` for the PropertyGroup + string field. + + Finish: + Read fresh pset → keep ``path_data`` → gather ``general`` kwargs + (project units) → reassemble → ``is_editing = False`` → call + ``_update_pset`` (per-type pset writer) → call ``_update_modifier_ifc_data`` + (per-type geometry commit). + + Cancel: + Read fresh pset → restore draft props → call + ``_update_modifier_bmesh`` (per-type bmesh preview) → + ``is_editing = False``.""" + + @classmethod + def _post_load_data(cls, data: dict) -> dict: + """Hook: optionally transform the pset data dict after loading and before + passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through. + + Railing overrides to JSON-serialise ``path_data`` (its + BIMRailingProperties.path_data is a ``StringProperty`` holding JSON).""" + return data + + @classmethod + def _update_pset(cls, element: entity_instance, data: dict) -> None: + """Hook: per-type pset writer (``update_bbim__pset``).""" + raise NotImplementedError + + @classmethod + def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Hook: per-type ``update__modifier_ifc_data`` — commits the + modified geometry to IFC. Signature accepts ``(obj, context)`` so + subclasses can forward either argument to their existing helper.""" + raise NotImplementedError + + @classmethod + def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Hook: per-type ``update__modifier_bmesh`` — rebuilds the + bmesh preview to match the current draft props (used by Cancel).""" + raise NotImplementedError + + @classmethod + def _enable_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + _element, props = resolved + data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"] + data = cls._post_load_data(data) + props.set_props_kwargs_from_ifc_data(data) + props.is_editing = True + + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name) + path_data = pset_data["data_dict"]["path_data"] + data = props.get_general_kwargs(convert_to_project_units=True) + data["path_data"] = path_data + cls._update_pset(element, data) + cls._update_modifier_ifc_data(obj, context) + # Set only on success: if any IFC op above raised, the user's draft survives for retry. + props.is_editing = False + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + _element, props = resolved + data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"] + data = cls._post_load_data(data) + props.set_props_kwargs_from_ifc_data(data) + cls._update_modifier_bmesh(obj, context) + props.is_editing = False + + def _enable_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._enable_one(obj) + return {"FINISHED"} + + def _finish_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._finish_one(obj, context) + return {"FINISHED"} + + def _cancel_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._cancel_one(obj, context) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 97980d92dd..02934f701d 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import os import platform @@ -380,6 +382,76 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup): cycle: bool +class GizmoPreferencesWall(bpy.types.PropertyGroup): + """Property group for wall gizmo visibility settings.""" + + length: BoolProperty( + name="Length", + default=True, + description="Show the length dimension gizmo along the wall axis.", + ) + height: BoolProperty( + name="Height", + default=True, + description="Show the height dimension gizmo at the wall's start endpoint.", + ) + height_end: BoolProperty( + name="Height (far end, walls > 5m)", + default=True, + description=( + "Show a second height gizmo at the wall's far end so long walls don't " + "require panning to reach the handle." + ), + ) + x_angle: BoolProperty( + name="Slope", + default=True, + description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.", + ) + cycle: BoolProperty( + name="Cycle Offset Baseline", + default=True, + description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.", + ) + scissors: BoolProperty( + name="Split at cursor", + default=True, + description="Show the split icon at the 3D cursor when it lies within the wall's length range.", + ) + extend: BoolProperty( + name="Extend length to cursor X", + default=True, + description="Show the extend-length icon at the 3D cursor's projected wall-axis X.", + ) + extend_height: BoolProperty( + name="Extend height to cursor Z", + default=True, + description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.", + ) + rotate: BoolProperty( + name="Rotate 90°", + default=True, + description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).", + ) + toggle_openings: BoolProperty( + name="Toggle Openings", + default=True, + description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).", + ) + + if TYPE_CHECKING: + length: bool + height: bool + height_end: bool + x_angle: bool + cycle: bool + scissors: bool + extend: bool + extend_height: bool + rotate: bool + toggle_openings: bool + + class GizmoPreferences(bpy.types.PropertyGroup): """Property group for all gizmo visibility settings.""" @@ -391,12 +463,14 @@ class GizmoPreferences(bpy.types.PropertyGroup): door: bpy.props.PointerProperty(type=GizmoPreferencesDoor) window: bpy.props.PointerProperty(type=GizmoPreferencesWindow) stair: bpy.props.PointerProperty(type=GizmoPreferencesStair) + wall: bpy.props.PointerProperty(type=GizmoPreferencesWall) if TYPE_CHECKING: draw_gizmos_in_3d_viewport: bool door: GizmoPreferencesDoor window: GizmoPreferencesWindow stair: GizmoPreferencesStair + wall: GizmoPreferencesWall class DocPreferences(bpy.types.PropertyGroup): @@ -849,49 +923,56 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters) bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters) bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters) + bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters) + + def _draw_parametric_gizmo_parameters( + self, + layout: bpy.types.UILayout, + gizmo_pg: bpy.types.PropertyGroup, + dimension_gizmo_class: type, + special_gizmo_names: frozenset[str] = frozenset(), + ) -> None: + """Draw the per-element gizmo visibility toggles. Surfaces every annotation + on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s + dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons + like baseline cycle, scissors, rotate, …).""" + visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names + try: + annotations = gizmo_pg.__annotations__ + except AttributeError: + annotations = type(gizmo_pg).__annotations__ + for prop in annotations: + if prop in visible_names: + layout.prop(gizmo_pg, prop) def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: from bonsai.bim.module.model.door import GizmoDoorEdition - door_gizmos = self.gizmos.door - gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props} - # Add special gizmos not in dimension_gizmo_props - gizmo_prop_names.update(("swing_arc", "flip_arc")) - try: - annotations = door_gizmos.__annotations__ - except AttributeError: - annotations = type(door_gizmos).__annotations__ - for prop in annotations: - if prop in gizmo_prop_names: - layout.prop(door_gizmos, prop) + self._draw_parametric_gizmo_parameters( + layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"}) + ) def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: from bonsai.bim.module.model.window import GizmoWindowEdition - window_gizmos = self.gizmos.window - gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props} - try: - annotations = window_gizmos.__annotations__ - except AttributeError: - annotations = type(window_gizmos).__annotations__ - for prop in annotations: - if prop in gizmo_prop_names: - layout.prop(window_gizmos, prop) + self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition) def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: from bonsai.bim.module.model.stair import GizmoStairEdition - stair_gizmos = self.gizmos.stair - gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props} - # Add special gizmos not in dimension_gizmo_props - special_gizmo_names = {"lock", "plus", "minus", "cycle"} - try: - annotations = stair_gizmos.__annotations__ - except AttributeError: - annotations = type(stair_gizmos).__annotations__ - for prop in annotations: - if prop in gizmo_prop_names or prop in special_gizmo_names: - layout.prop(stair_gizmos, prop) + self._draw_parametric_gizmo_parameters( + layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"}) + ) + + def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + from bonsai.bim.module.model.wall import GizmoWallEdition + + self._draw_parametric_gizmo_parameters( + layout, + self.gizmos.wall, + GizmoWallEdition, + frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}), + ) def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "occurrence_name_style") diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index e975505381..7f4237fb45 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -15,9 +15,12 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations +import math from typing import TYPE_CHECKING, Literal, Optional if TYPE_CHECKING: @@ -173,3 +176,141 @@ class RequireAtLeastTwoElements(Exception): class RequireLayeredElement(Exception): pass + + +# --- Wall geometry math (pure) ------------------------------------------------ +# Tuple in / tuple out so these helpers run under ``pytest test/core/`` without +# ``bpy`` or ``mathutils``. Callers convert ``mathutils.Vector`` at the boundary. + + +def baseline_from_offset(offset: float, thickness: float, tolerance: float = 0.001) -> str: + """Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR. + + Mirrors the math in ``tool.Model.offset_wall`` for both POSITIVE and NEGATIVE + direction_sense walls. Returns the closest canonical baseline; falls back to + ``"CENTER"`` when nothing is within ``tolerance``.""" + candidates = ( + ("EXTERIOR", 0.0), + ("CENTER", -thickness / 2), + ("INTERIOR", -thickness), + ("EXTERIOR", thickness), + ("CENTER", thickness / 2), + ("INTERIOR", 0.0), + ) + best = min(candidates, key=lambda c: abs(offset - c[1])) + return best[0] if abs(offset - best[1]) < tolerance else "CENTER" + + +def project_axis_intersection( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + parallel_threshold: float, +) -> Optional[tuple[float, float, float]]: + """Compute the 2D (X,Y plane) intersection of two world-space axis segments. + + Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple + (Z is the average of the four input Zs, for visual placement) or ``None`` if + the segments are parallel within ``parallel_threshold`` (a dot-product magnitude + threshold — e.g. ``cos(2°) ≈ 0.9994`` treats walls within 2° of parallel as parallel).""" + p1, p2 = seg_a + p3, p4 = seg_b + d1x, d1y = p2[0] - p1[0], p2[1] - p1[1] + d2x, d2y = p4[0] - p3[0], p4[1] - p3[1] + d1_len = (d1x * d1x + d1y * d1y) ** 0.5 + d2_len = (d2x * d2x + d2y * d2y) ** 0.5 + if d1_len < 1e-9 or d2_len < 1e-9: + return None + dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len) + if abs(dot) >= parallel_threshold: + return None + denom = d1x * d2y - d1y * d2x + if abs(denom) < 1e-9: + return None + t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom + ix = p1[0] + t * d1x + iy = p1[1] + t * d1y + iz = (p1[2] + p2[2] + p3[2] + p4[2]) / 4 + return (ix, iy, iz) + + +def displacement_from_x_angle(height: float, x_angle: float) -> float: + """Top-edge horizontal displacement for a wall of given vertical ``height`` and + slope ``x_angle`` (radians). Drives the slope dimension gizmo's display value. + + Inverse of :func:`x_angle_from_displacement`.""" + return height * math.tan(x_angle) + + +def x_angle_from_displacement(height: float, displacement: float) -> float: + """Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement. + + ``height`` is clamped to ``max(height, 1e-6)`` so vertical walls of effectively + zero height map cleanly to ``±π/2`` via ``atan2`` rather than dividing by zero. + + Inverse of :func:`displacement_from_x_angle`.""" + return math.atan2(displacement, max(height, 1e-6)) + + +def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) -> float: + """Vertical height of a wall given its slanted extrusion depth and slope. + + ``IfcExtrudedAreaSolid.Depth`` measures along the (possibly slanted) extrusion + direction. The vertical height the user thinks of is ``depth * cos(x_angle)``. + Unit-agnostic: the result is in the same units as ``extrusion_depth``.""" + return extrusion_depth * abs(math.cos(x_angle)) + + +def are_axes_collinear( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + parallel_threshold: float = 0.9994, + line_tolerance: float = 0.05, +) -> bool: + """True if both axis segments lie on the same infinite line in plan. + + Two conditions: directions must be (anti-)parallel within ``parallel_threshold`` + (``cos(2°) ≈ 0.9994``), AND any endpoint of B must lie on A's infinite line + within ``line_tolerance``. Plan-only (Z ignored) — two parallel walls at + different elevations are still considered collinear because the merge operator + handles Z resolution itself. + + Used by the wall-join gizmo's state machine: collinear pair → Merge icon at the + boundary, perpendicular pair → Join icon at the intersection.""" + d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1] + d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1] + d1_len = (d1x * d1x + d1y * d1y) ** 0.5 + d2_len = (d2x * d2x + d2y * d2y) ** 0.5 + if d1_len < 1e-9 or d2_len < 1e-9: + return False + if abs((d1x * d2x + d1y * d2y) / (d1_len * d2_len)) < parallel_threshold: + return False + # Project seg_b[0] onto the infinite line through seg_a; the perpendicular + # distance to the original point tells us how far off the line B sits. + nx, ny = d1x / d1_len, d1y / d1_len + dx, dy = seg_b[0][0] - seg_a[0][0], seg_b[0][1] - seg_a[0][1] + t = dx * nx + dy * ny + proj_x = seg_a[0][0] + nx * t + proj_y = seg_a[0][1] + ny * t + perp_x = seg_b[0][0] - proj_x + perp_y = seg_b[0][1] - proj_y + return (perp_x * perp_x + perp_y * perp_y) ** 0.5 < line_tolerance + + +def closest_endpoint_midpoint( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], +) -> tuple[float, float, float]: + """Midpoint of the closest pair of endpoints between two segments. + + For walls that meet end-to-end this is the shared corner; for walls with a + small gap it's the midpoint of the gap. Either way it's the user-meaningful + "boundary" where a merge would graft the two segments together.""" + endpoints_a = (seg_a[0], seg_a[1]) + endpoints_b = (seg_b[0], seg_b[1]) + + def _distance_sq(p: tuple[float, float, float], q: tuple[float, float, float]) -> float: + return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2 + + closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair)) + a, b = closest_pair + return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index e6a60c9deb..6bb0f96975 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -776,6 +776,12 @@ class Profile: def get_profile(cls, element): pass +@interface +class Parametric: + def get_geom_generation(cls) -> int: pass + def refresh_post_commit(cls) -> None: pass + + @interface class Pset: def add_proposed_property(cls, name, value, props): pass diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 06e498e8be..31e93cace7 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -51,6 +51,7 @@ from bonsai.tool.misc import Misc from bonsai.tool.model import Model from bonsai.tool.nest import Nest from bonsai.tool.owner import Owner +from bonsai.tool.parametric import Parametric from bonsai.tool.patch import Patch from bonsai.tool.polyline import Polyline from bonsai.tool.profile import Profile diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index fac9657dbd..53f91c06da 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations @@ -55,12 +57,12 @@ from mathutils import Matrix, Vector import bonsai.bim import bonsai.core.tool import bonsai.tool as tool -from bonsai.bim.ifc import IFC_CONNECTED_TYPE if TYPE_CHECKING: import bpy.stub_internal.rna_enums as rna_enums from sun_position.properties import SunPosProperties + from bonsai.bim.ifc import IFC_CONNECTED_TYPE from bonsai.bim.module.attribute.prop import BIMAttributeProperties from bonsai.bim.module.constraint.prop import ( BIMConstraintProperties, @@ -1137,20 +1139,18 @@ class Blender(bonsai.core.tool.Blender): :return: True if an action was taken, False otherwise """ + # roof and railing both finalize then drop into path-edit mode — handle + # them before the generic finish dispatch so the path transition runs. if cls.is_roof(element): - if cls.is_editing_roof_parameters(obj): - bpy.ops.bim.finish_editing_roof() + if (feature := tool.Parametric.find_by_name("roof")) and feature.is_editing(obj): + tool.Parametric.run_bim_op(feature.finish_op) bpy.ops.bim.enable_editing_roof_path() elif cls.is_railing(element): - if cls.is_editing_railing_parameters(obj): - bpy.ops.bim.finish_editing_railing() + if (feature := tool.Parametric.find_by_name("railing")) and feature.is_editing(obj): + tool.Parametric.run_bim_op(feature.finish_op) bpy.ops.bim.enable_editing_railing_path() - elif cls.is_editing_stair_parameters(obj): - bpy.ops.bim.finish_editing_stair() - elif cls.is_editing_door_parameters(obj): - bpy.ops.bim.finish_editing_door() - elif cls.is_editing_window_parameters(obj): - bpy.ops.bim.finish_editing_window() + elif feature := tool.Parametric.is_object_editing(obj): + tool.Parametric.run_bim_op(feature.finish_op) else: return False return True @@ -1161,20 +1161,13 @@ class Blender(bonsai.core.tool.Blender): :return: True if an action was taken, False otherwise """ + # Path-edit modes are distinct from parametric draft modes; handle them first. if cls.is_editing_railing_path(obj): bpy.ops.bim.cancel_editing_railing_path() elif cls.is_editing_roof_path(obj): bpy.ops.bim.cancel_editing_roof_path() - elif cls.is_editing_railing_parameters(obj): - bpy.ops.bim.cancel_editing_railing() - elif cls.is_editing_door_parameters(obj): - bpy.ops.bim.cancel_editing_door() - elif cls.is_editing_window_parameters(obj): - bpy.ops.bim.cancel_editing_window() - elif cls.is_editing_roof_parameters(obj): - bpy.ops.bim.cancel_editing_roof() - elif cls.is_editing_stair_parameters(obj): - bpy.ops.bim.cancel_editing_stair() + elif feature := tool.Parametric.is_object_editing(obj): + tool.Parametric.run_bim_op(feature.cancel_op) else: return False return True @@ -1221,6 +1214,17 @@ class Blender(bonsai.core.tool.Blender): def is_stair(cls, element: entity_instance) -> bool: return tool.Pset.get_element_pset(element, "BBIM_Stair") + @classmethod + def is_wall(cls, element: entity_instance) -> bool: + """A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage. + + Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset — + their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage, + IfcExtrudedAreaSolid). Any LAYER2 wall qualifies.""" + if not element.is_a("IfcWall"): + return False + return tool.Model.get_usage_type(element) == "LAYER2" + @classmethod def is_editing_railing_path(cls, obj: bpy.types.Object): props = tool.Model.get_railing_props(obj) @@ -1231,34 +1235,10 @@ class Blender(bonsai.core.tool.Blender): props = tool.Model.get_roof_props(obj) return props.is_editing_path - @classmethod - def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_railing_props(obj) - return props.is_editing - - @classmethod - def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_roof_props(obj) - return props.is_editing - - @classmethod - def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_window_props(obj) - return props.is_editing - - @classmethod - def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_door_props(obj) - return props.is_editing - - @classmethod - def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_stair_props(obj) - return props.is_editing - @classmethod def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool: - return cls.is_stair(element) or cls.is_door(element) or cls.is_window(element) + feature = tool.Parametric.find_for_element(element) + return bool(feature and feature.has_non_editable_path) class Array: @classmethod diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 281265cfcc..1f103c4c5e 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations @@ -77,6 +79,7 @@ if TYPE_CHECKING: BIMRoofProperties, BIMStairProperties, BIMSverchokProperties, + BIMWallProperties, BIMWindowProperties, ) @@ -98,6 +101,10 @@ class Model(bonsai.core.tool.Model): def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties: return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def get_wall_props(cls, obj: bpy.types.Object) -> BIMWallProperties: + return obj.BIMWallProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties: return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue] diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py new file mode 100644 index 0000000000..ce6659976c --- /dev/null +++ b/src/bonsai/bonsai/tool/parametric.py @@ -0,0 +1,460 @@ +# 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. + +"""Registry + save-time auto-commit for parametric draft edits. + +Single source of truth: adding a new parametric element type is one entry in +`Parametric.EDIT_TYPES`. Every consumer — save-time auto-commit, the +finish/cancel chains in ``tool.Blender.Modifier``, the ``PointerProperty`` +attachment in ``bim/module/model/__init__.py``, and the per-type +``GizmoPreferences`` registration in ``bim/__init__.py`` — derives the +class names, operator ``bl_idname``s, and predicates from the registry entry's +short ``name`` token. + +Lives in ``tool/`` so both ``tool/`` (e.g. ``tool/blender.py``) and ``bim/`` +modules can consume it without crossing the layer boundary. The orchestration +helpers (``commit_object_draft``, ``commit_pending_edits``) call +``bpy.ops.bim.*`` operators by name, which is runtime dispatch through Blender +rather than a Python import of ``bim/``. + +---------------------------------------------------------------------- +How to add a new parametric object +---------------------------------------------------------------------- + +End-to-end walkthrough for wiring a new IFC element type (e.g. ``IfcSlab``) +into the gizmo-driven parametric edit framework. Numbered steps are +**required** unless flagged OPTIONAL. Keep this section in sync with the +implementation files it references — if a step's example code stops matching +the real registration site, the step is out of date. + +STEP 1 — Add the registry entry (this file) + Append to `Parametric.EDIT_TYPES`:: + + ParametricObject("slab", has_non_editable_path=False), + + The ``name`` token drives every derived identifier: + ``BIMSlabProperties``, ``bim.enable_editing_slab`` / + ``bim.finish_editing_slab`` / ``bim.cancel_editing_slab``, and the + ``slab`` field on ``GizmoPreferences``. Set ``has_non_editable_path=True`` + if the modifier exposes no user-editable path (cf. door, window, stair). + +STEP 2 — Define the ``PropertyGroup`` (``bim/module/model/prop.py``) + Class name **must** be ``BIMProperties`` — capitalisation matches + `ParametricObject.props_attr`:: + + class BIMSlabProperties(bpy.types.PropertyGroup): + is_editing: BoolProperty(...) + # ... per-type draft fields, snapshots, mesh_dirty, etc. ... + + The ``is_editing`` flag is the single field every consumer of the registry + expects. + +STEP 3 — Register the PropertyGroup class + Add it to the ``classes`` tuple in ``bim/module/model/__init__.py`` (near + the existing ``prop.BIMProperties`` entries). The + ``bpy.types.Object.BIMSlabProperties`` attachment is automatic — + `Parametric.register_object_properties` loops the registry. + +STEP 4 — Implement the Enable / Finish / Cancel triad + In ``bim/module/model/slab.py``, define three ``bpy.types.Operator`` + subclasses with the canonical ``bl_idname``\\s: + + - ``EnableEditingSlab`` → ``bl_idname = "bim.enable_editing_slab"`` + - ``FinishEditingSlab`` → ``bl_idname = "bim.finish_editing_slab"`` + - ``CancelEditingSlab`` → ``bl_idname = "bim.cancel_editing_slab"`` + + **First, check if your new type fits one of the existing lifecycle + shapes** in `bonsai.bim.parametric_lifecycle`. If it does, inherit + the matching mixin and the triad collapses to ~25 lines total: + + - ``FeatureModifierEditMixin`` — BBIM_ pset with nested + ``lining_properties`` / ``panel_properties``; Finish via + ``update__modifier_representation`` → + ``ifcopenshell.api.feature``; Cancel via + ``switch_representation`` to the Body rep. Reference samples: + door (multi-object) and window (single-object). + + - ``PathPreservingEditMixin`` — BBIM_ pset whose ``path_data`` + is preserved through edit; Finish via per-type + ``update_bbim__pset`` + ``update__modifier_ifc_data``; + Cancel rebuilds the bmesh preview. Reference samples: railing, roof. + + If neither shape fits (the type needs validation-first lifecycle, an + explicit snapshot, delegate-to-sub-operators Finish, or a unique + post-Finish step) implement the triad standalone — see ``wall.py`` + (validation/snapshot/delegate) or ``stair.py`` (raw pset JSON + + ``update_ifc_stair_props``) as references. Register all three in the + module's ``classes`` tuple. + +STEP 5 — Implement the gizmo group (same file) + Subclass ``BaseParametricGizmoGroup`` from + ``bim/module/drawing/gizmos.py``:: + + class GizmoSlabEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup): + bl_idname = "OBJECT_GGT_bim_slab_edition" + + @classmethod + def is_element_type(cls, element): + return tool.Blender.Modifier.is_slab(element) + + dimension_gizmo_props = [DimensionGizmoConfig(...)] + + Register it in the ``classes`` tuple. The classmethod makes + ``tool.Blender.Modifier.is_slab(element)`` testable via the gizmo's + ``poll()``. + +STEP 6 — Add the element-type predicate (``tool/blender.py``) + Inside the ``Blender.Modifier`` class, alongside ``is_door`` / ``is_wall``:: + + @classmethod + def is_slab(cls, element: entity_instance) -> bool: + return tool.Pset.get_element_pset(element, "BBIM_Slab") + + The method name **must** be ``is_`` to match + `ParametricObject.name` — `Parametric.find_for_element` + looks it up by string. + +STEP 7 — OPTIONAL: typed property accessor (``tool/model.py``) + Convenience helper for call sites that statically know the IFC type:: + + @classmethod + def get_slab_props(cls, obj) -> BIMSlabProperties: + return obj.BIMSlabProperties + + Call sites that work generically (registry-driven) can use + ``getattr(obj, feature.props_attr)`` directly and skip this step. + +STEP 8 — OPTIONAL: gizmo visibility preferences (``bim/ui.py``) + For per-gizmo show/hide toggles, define:: + + class GizmoPreferencesSlab(bpy.types.PropertyGroup): + length: BoolProperty(name="Length", default=True, ...) + # ... one BoolProperty per gizmo ... + + Then add a matching field on ``GizmoPreferences``:: + + slab: bpy.props.PointerProperty(type=GizmoPreferencesSlab) + + Do **not** add ``GizmoPreferencesSlab`` to the ``classes`` list in + ``bim/__init__.py`` — the registry-driven discovery in this module finds + it by name (``GizmoPreferences`` + capitalised registry token) and + registers it automatically. + +STEP 9 — OPTIONAL: pure geometry helpers (``core/model.py``) + Per-type math (collinearity checks, slope/displacement conversions, + intersection helpers) lives here. The hard rule: no ``bpy`` / + ``ifcopenshell`` imports at module load — wrap them in + ``if TYPE_CHECKING:`` blocks only. Lets the helpers be unit-tested + headless via ``pytest test/core/``. + +STEP 10 — Verify + From ``src/bonsai/``:: + + ruff check . + black --check . + pytest test/core/ -x -q + blender -b -P runpytest.py -- test/bim/ -x -q -m model + + The Blender-backed lane runs a registry smoke test that iterates the + EDIT_TYPES list and asserts each entry's enable/finish/cancel operator + resolves to a registered ``bpy.ops.bim.*``, that ``bpy.types.Object`` + carries the matching ``BIMProperties`` attribute, and that the + ``is_`` predicate exists on ``tool.Blender.Modifier``. Forget any + of the steps above and that test fails with a precise pointer at + what's missing. + + Then manually in Blender: + + 1. Enable Bonsai → create an instance of the new IFC type. + 2. Run ``bim.enable_editing_`` → confirm the gizmo group polls in + and the dimension handles appear. + 3. Modify a draft field, save the file → confirm auto-commit fires + (watch the console for the ``parametric_commit`` log line). + 4. Disable + re-enable the addon → no ``bpy_struct: unknown property + type`` errors in the console (validates the register/unregister + symmetry driven by the registry).""" + +from __future__ import annotations + +import re +import traceback +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +import bpy + +import bonsai.core.tool +import bonsai.tool as tool + +if TYPE_CHECKING: + from ifcopenshell import entity_instance + + +# ``name`` must be a single ASCII lowercase token starting with a letter: +# ``str.capitalize()`` only handles single-word names cleanly, so a compound +# token like ``"curtain_wall"`` would derive ``"BIMCurtain_wallProperties"`` — +# off the Bonsai naming convention and silently broken. +_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*$") + + +@dataclass(frozen=True) +class ParametricObject: + """One parametric element type's draft + enable + finish + cancel triad. + + The short ``name`` token ("door", "window", "stair", "railing", "roof", + "wall", …) drives every derived identifier: the ``BIMProperties`` + attribute on ``bpy.types.Object`` and the ``bim.enable_editing_`` / + ``bim.finish_editing_`` / ``bim.cancel_editing_`` operator + ``bl_idname``s. The ``name`` is validated at construction time — a + multi-word IFC type would silently mis-derive through + ``str.capitalize()`` and breaks the single-token assumption. + + ``has_non_editable_path`` flags element types whose modifier exposes no + user-editable path (door, window, stair). + + The paired runtime predicate ``tool.Blender.Modifier.is_(element)`` + is part of the registry contract: it MUST be **total** — accept any + IFC entity and return a boolean, never raise. The registry iterates + every predicate against the active element on save; a raising predicate + propagates upward and breaks the save path for *all* parametric types, + not just its own.""" + + name: str + has_non_editable_path: bool = False + + def __post_init__(self) -> None: + if not _VALID_NAME_RE.match(self.name): + raise ValueError( + f"ParametricObject name {self.name!r} must be a single ASCII lowercase " + f"token matching {_VALID_NAME_RE.pattern!r}. ``str.capitalize()`` only " + f"handles single-word names — compound IFC types need an explicit " + f"naming override (not yet supported)." + ) + + @property + def props_attr(self) -> str: + return f"BIM{self.name.capitalize()}Properties" + + @property + def enable_op(self) -> str: + return f"bim.enable_editing_{self.name}" + + @property + def finish_op(self) -> str: + return f"bim.finish_editing_{self.name}" + + @property + def cancel_op(self) -> str: + return f"bim.cancel_editing_{self.name}" + + def is_editing(self, obj: bpy.types.Object) -> bool: + props = getattr(obj, self.props_attr, None) + return bool(props and getattr(props, "is_editing", False)) + + +class Parametric(bonsai.core.tool.Parametric): + EDIT_TYPES: list[ParametricObject] = [ + ParametricObject("door", has_non_editable_path=True), + ParametricObject("window", has_non_editable_path=True), + ParametricObject("stair", has_non_editable_path=True), + ParametricObject("railing"), + ParametricObject("roof"), + ParametricObject("wall"), + ] + + _geom_generation: int = 0 + + @classmethod + def get_geom_generation(cls) -> int: + return cls._geom_generation + + @classmethod + def refresh_post_commit(cls) -> None: + """Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level + ``BIMModelProperties`` (workspace tool header H/L/A fields) from current + IFC state and bumps the geometry generation counter so per-gizmo-group + caches keyed off it drop their stale entries on the next draw. + + Why this exists: ``update_bim_tool_props`` was historically only wired + to the active-object msgbus, so in-place IFC mutations on the current + selection (S_E, C_E, change_extrusion_*, …) left the header showing + stale values until the user changed selection. Same shape of bug for + the wall gizmo cache: ``GizmoGroup.refresh()`` only fires on Blender's + own state-change events, not on every ``bpy.ops.bim.*`` mutation. + + Cheap when nothing parametric is active — ``update_bim_tool_props`` + early-returns when no Bonsai workspace tool is selected or the active + object isn't an IFC element.""" + import bonsai.bim.handler # late import: bim.handler imports tool.* + + cls._geom_generation += 1 + bonsai.bim.handler.update_bim_tool_props() + screen = getattr(bpy.context, "screen", None) + if screen is not None: + for area in screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + + @classmethod + def find_by_name(cls, name: str) -> Optional[ParametricObject]: + return next((f for f in cls.EDIT_TYPES if f.name == name), None) + + @classmethod + def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]: + """Return the registry entry whose IFC type predicate matches ``element``. + + The per-type predicate lives at ``tool.Blender.Modifier.is_``; + resolved here by attribute lookup at call time, which avoids a + ``tool.parametric`` ↔ ``tool.blender`` import cycle.""" + for feature in cls.EDIT_TYPES: + predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None) + if predicate is not None and predicate(element): + return feature + return None + + @classmethod + def is_object_editing(cls, obj: bpy.types.Object) -> Optional[ParametricObject]: + for feature in cls.EDIT_TYPES: + if feature.is_editing(obj): + return feature + return None + + @classmethod + def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]: + """``(object, finish_operator_bl_idname)`` pairs for every object with + an in-progress parametric draft. The first registry match per object wins.""" + return [(obj, feature.finish_op) for obj in bpy.data.objects if (feature := cls.is_object_editing(obj))] + + @classmethod + def run_bim_op(cls, bl_idname: str) -> None: + """Invoke a ``bim.*`` operator by its ``bl_idname``. + + Constraint enforced via ``assert``: the operator MUST be a + ``tool.Ifc.Operator`` subclass — its transaction wrap is what + makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation + of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's + transaction system.""" + verb = bl_idname.removeprefix("bim.") + op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None) + assert op_cls is not None and issubclass( + op_cls, tool.Ifc.Operator + ), f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation" + getattr(bpy.ops.bim, verb)() + + @classmethod + def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool: + """Run ``finish_op`` scoped to ``obj`` alone. Returns True on success, False if + the operator raised (with traceback printed to the console). + + Both ``temp_override`` and ``view_layer.objects.active`` are set: + ``temp_override`` does not rebind ``objects.active``, and some finish + operators read it directly.""" + view_layer = bpy.context.view_layer + original_active = view_layer.objects.active + try: + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + view_layer.objects.active = obj + try: + cls.run_bim_op(finish_op) + return True + except Exception as e: + print(f"Bonsai: commit of {obj.name!r} via {finish_op} failed: {e}") + traceback.print_exc() + return False + finally: + view_layer.objects.active = original_active + + @classmethod + def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]: + """Run each pending draft's finish operator scoped to its object. + + A per-object failure does not abort the loop — remaining drafts still + flush, otherwise the auto-commit would ship the exact silent-desync + it exists to prevent. + + Each finish op wraps its own IFC transaction, so N pending drafts + produce N+1 undo entries (one per commit, plus the save). Ctrl+Z + walks back through commits individually — intentional, each commit + is reversible on its own.""" + committed = 0 + failed: list[bpy.types.Object] = [] + for obj, finish_op in cls.get_pending_edits(): + if cls.commit_object_draft(obj, finish_op): + committed += 1 + else: + failed.append(obj) + return committed, failed + + @classmethod + def commit_pending_edits_for_selection( + cls, names: Optional[tuple[str, ...]] = None + ) -> tuple[int, list[bpy.types.Object]]: + """Selection-scoped variant of `commit_pending_edits`. ``names`` + filters which registry entries to consider — e.g. ``("wall",)`` to commit + only wall drafts among selected objects; ``None`` considers every type. + + Used by multi-object operators (``bim.unjoin_walls``, ``bim.merge_wall``, + ``bim.extend_walls_to_wall`` etc.) that must run against committed IFC + state — running them with a wall whose draft hasn't been flushed leaves + stale gizmos pointing at obsolete IFC numbers.""" + committed = 0 + failed: list[bpy.types.Object] = [] + for obj in tool.Blender.get_selected_objects(): + feature = cls.is_object_editing(obj) + if feature is None: + continue + if names is not None and feature.name not in names: + continue + if cls.commit_object_draft(obj, feature.finish_op): + committed += 1 + else: + failed.append(obj) + return committed, failed + + @classmethod + def register_object_properties(cls, prop_module) -> None: + """Attach ``bpy.types.Object.BIMProperties`` for every registered + parametric type, looking up the matching ``PropertyGroup`` class on + ``prop_module``. Skips entries whose ``PropertyGroup`` class is absent.""" + for feature in cls.EDIT_TYPES: + prop_cls = getattr(prop_module, feature.props_attr, None) + if prop_cls is None: + continue + setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls)) + + @classmethod + def unregister_object_properties(cls) -> None: + for feature in cls.EDIT_TYPES: + if hasattr(bpy.types.Object, feature.props_attr): + delattr(bpy.types.Object, feature.props_attr) + + @classmethod + def iter_gizmo_preference_classes(cls, ui_module) -> list[type]: + """``GizmoPreferences`` classes that exist on ``ui_module`` for + every registry entry. Order matches `EDIT_TYPES`. Used by + ``bim/__init__.py`` to inject the per-type ``GizmoPreferences`` + classes at the correct point — before ``ui.GizmoPreferences``, which + references them via ``PointerProperty``.""" + out: list[type] = [] + for feature in cls.EDIT_TYPES: + gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None) + if gpref is not None: + out.append(gpref) + return out diff --git a/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst b/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst index 33c4bd766a..6298cedda3 100644 --- a/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst +++ b/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst @@ -51,6 +51,62 @@ To use these tools: 2. Use the appropriate shortcut or select the tool from the top bar. 3. Follow the on-screen prompts or adjust parameters as needed. +Interactive Parametric Editing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Selected walls expose an in-viewport parametric edit mode that mirrors the door / +window / stair pen-icon UI: + +1. Select a single wall. A pen (Edit Wall) icon appears next to the wall in the + 3D viewport, and a matching ``Edit Wall`` button is available in the + ``Parametric Geometry`` tab of the N panel. +2. Click the pen icon (or the panel button) to enter edit mode. Dimension + gizmos for length, height, slope (x-angle) and the layer offset baseline + appear around the wall. +3. Drag any handle to update the value. Dragging only modifies the in-progress + draft — the IFC file is not touched until you commit, so dragging a length + handle through many intermediate values produces zero extra IFC entities. +4. Click the green ✓ icon to commit; click the red ✗ to discard. Pressing the + ✓ icon on a wall that hasn't been dragged is a true byte-identical no-op — + the IFC file is unchanged. + +While editing, additional gizmos surface based on context: + +- **Cycle Baseline**: cycles the layer offset baseline (Exterior → Centreline → + Interior). Shift+click cycles in reverse. +- **3D-cursor scissors**: appears when the 3D cursor sits on the wall axis; + clicking splits the wall at the cursor's projected X. +- **3D-cursor extend (horizontal)**: appears when the 3D cursor sits beyond the + wall axis; clicking extends the wall to the cursor's projected X. +- **3D-cursor extend (vertical)**: appears when the 3D cursor sits above / + below the wall; clicking extends the wall's height to the cursor's Z. +- **Rotate 90°**: rotates the wall around its Z axis. +- **Show / hide openings**: toggles opening fill visibility (doors and windows). + +When two walls are selected, the gizmo switches to a state-aware icon at their +common point: + +- Already joined → an Unjoin icon at the shared corner. +- Collinear (same axis line) → a Merge icon at the boundary midpoint. +- Joinable corner → a Join icon at the floor + an Extend-To-Wall icon at the + active wall's top. + +When a wall and a slab (LAYER3 element) are selected, an Extend-Vertically icon +appears at the wall's origin / slab elevation; clicking dispatches +``bim.extend_walls_to_underside``. + +When a wall and a non-wall, non-slab object are selected, an Add-Opening icon +appears above the wall at the other object's projected X. + +Auto-commit on save +~~~~~~~~~~~~~~~~~~~ + +Pressing Ctrl+S (or running ``bim.save_project``) while any wall is mid-edit +flushes every pending parametric draft first — the same Apply-Wall-Edits the ✓ +icon performs, scoped per wall. The IFC saved on disk reflects the values the +user dragged, not the snapshot taken when edit mode was entered. Each commit +produces its own undo entry, so Ctrl+Z walks back through commits individually. + Aligning Walls ^^^^^^^^^^^^^^ diff --git a/src/bonsai/runpytest.py b/src/bonsai/runpytest.py index 9a00ea69b0..88e1472095 100755 --- a/src/bonsai/runpytest.py +++ b/src/bonsai/runpytest.py @@ -17,18 +17,46 @@ # along with Bonsai. If not, see . """ -Requires pytest installed under blender +Requires pytest installed under blender. -Usage: `blender -b -P runpytest.py -- ARGS` +Usage: + blender -b -P runpytest.py -- ARGS + +Alternative (when the calling shell strips or reorders the ``--`` separator +before it reaches Blender — observed with some PowerShell / wrapper-script +invocations on Windows): pass the same pytest args via the +``BONSAI_TEST_ARGS`` environment variable as a single shell-quoted string +and invoke without ``--``:: + + $env:BONSAI_TEST_ARGS = "test/bim/ -x -q" + blender -b -P runpytest.py """ +import os +import shlex import sys import pytest argv = [__file__] -if "--" in sys.argv: +env_args = os.environ.get("BONSAI_TEST_ARGS", "") +if env_args: + # POSIX-style quoting works on all three OSes — env var values are + # literal strings (no shell evaluation when Python reads them), and + # POSIX quoting (``'foo "bar baz" qux'`` → three tokens, quotes stripped) + # matches what most docs and examples use. + argv += shlex.split(env_args) + # On the env-var path the args never appear in Blender's argv at all, + # so any pytest plugin that reads ``sys.argv`` directly (instead of + # going through pytest's API) would otherwise see only Blender's own + # ``-b -P runpytest.py`` and miss the test args entirely. Shadow argv + # so those plugins see the pytest-shaped view they expect. + sys.argv = list(argv) +elif "--" in sys.argv: + # The traditional path: Blender forwards everything after ``--`` to the + # script via ``sys.argv``. ``sys.argv`` is deliberately left as Blender + # set it — pre-existing behavior, preserved. i = sys.argv.index("--") argv += sys.argv[i + 1 :] diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature index 064620a574..bfae14c6f6 100644 --- a/src/bonsai/test/bim/feature/model.feature +++ b/src/bonsai/test/bim/feature/model.feature @@ -673,6 +673,129 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e And I press "bim.finish_editing_door()" Then nothing happens +Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset + Given an empty IFC project + And I trigger "Add Element" + And I set the "Class" property to "IfcDoorType" + And I set the "Predefined Type" property to "DOOR" + And I set the "Representation" property to "Door" + When I click "OK" + And I press "bim.add_occurrence" + And I press "bim.enable_editing_door()" + And I set "active_object.BIMDoorProperties.overall_height" to "2.5" + Then "active_object.BIMDoorProperties.is_editing" is "True" + When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)" + Then "active_object.BIMDoorProperties.is_editing" is "False" + And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']" + And the variable "saved_height" equals "2.5" + +Scenario: Saving with no parametric edits in progress leaves the door pset unchanged + Given an empty IFC project + And I trigger "Add Element" + And I set the "Class" property to "IfcDoorType" + And I set the "Predefined Type" property to "DOOR" + And I set the "Representation" property to "Door" + When I click "OK" + And I press "bim.add_occurrence" + And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']" + When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)" + Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']" + And the variable "post_save_height" equals "{pre_save_height}" + +Scenario: Saving with a wall mid-edit auto-commits the draft to IFC + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" + And I press "bim.assign_class" + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.enable_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "True" + When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)" + Then "active_object.BIMWallProperties.is_editing" is "False" + +Scenario: Enabling and finishing a wall edit with no drag is a no-op + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" + And I press "bim.assign_class" + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And the variable "entity_count_before" is "len(list({ifc}))" + When I press "bim.enable_editing_wall()" + And I press "bim.finish_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + And "len(list({ifc}))" is "{entity_count_before}" + +Scenario: Cancelling a wall edit clears is_editing + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" + And I press "bim.assign_class" + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.enable_editing_wall()" + When I press "bim.cancel_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + +Scenario: Wall parametric edit works on IFC2X3 projects + Given an empty IFC2X3 project + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" + And I press "bim.assign_class" + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + When I press "bim.enable_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "True" + When I press "bim.finish_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + +Scenario: Rotate a wall 90° via bim.rotate_wall_90 + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + When I press "bim.rotate_wall_90()" + Then the object "IfcWall/Wall" dimensions are "1,0.1,3" + And the object "IfcWall/Wall" bottom left corner is at "0,0,0" + And the object "IfcWall/Wall" top right corner is at "-0.1,1,3" + +Scenario: Splitting a wall with another wall mid-edit commits the pending edit first + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.enable_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "True" + When I press "bim.split_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + Scenario: Create a door, undo and create a new door Given an empty IFC project And I prepare to undo diff --git a/src/bonsai/test/bim/module/drawing/test_dimension_gizmo_priority.py b/src/bonsai/test/bim/module/drawing/test_dimension_gizmo_priority.py new file mode 100644 index 0000000000..c3d30e4678 --- /dev/null +++ b/src/bonsai/test/bim/module/drawing/test_dimension_gizmo_priority.py @@ -0,0 +1,100 @@ +# 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. + +"""Regression guard: overlapping distance gizmos must let the smaller one win. + +When two ``GizmoDimension`` instances overlap on screen (e.g. a short dimension +nested inside a longer one along the same axis), the longer one's hit box fully +contains the shorter one's. Without a depth bias the longer one wins the GPU +select tie-break and the shorter one becomes unreachable. + +``GizmoDimension.set_dimension_length`` writes ``select_bias = -dimension_length`` +so the smaller one writes a higher (less-negative) bias and wins. The longer one +stays clickable at its exposed ends regardless of bias. + +We call ``set_dimension_length`` as an unbound method on a ``SimpleNamespace`` +fake ``self``. Its body only *writes* attributes (``_display_value``, +``_dimension_length``, ``select_bias``), so it doesn't need a real +``bpy.types.Gizmo`` instance — those only exist inside a registered +``GizmoGroup`` and aren't constructible in a headless test.""" + +import types +from types import SimpleNamespace + +import bpy +import pytest + +from bonsai.bim.module.drawing.gizmos import GizmoDimension + +pytestmark = pytest.mark.drawing + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def test_smaller_dimension_wins_select_bias(): + small = SimpleNamespace() + large = SimpleNamespace() + GizmoDimension.set_dimension_length(small, 0.077) + GizmoDimension.set_dimension_length(large, 0.109) + assert small.select_bias > large.select_bias + + +@pytest.mark.parametrize( + "lengths", + [ + [0.0, 0.05, 0.077, 0.109, 1.0, 5.0, 10.0], + [0.001, 0.5, 2.5, 100.0, 9999.0], + ], +) +def test_select_bias_is_non_increasing_in_length(lengths): + """A monotonic mapping is all Blender's GPU select needs to break the tie.""" + biases = [] + for length in lengths: + gizmo = SimpleNamespace() + GizmoDimension.set_dimension_length(gizmo, length) + biases.append(gizmo.select_bias) + for prev, curr in zip(biases, biases[1:]): + assert prev >= curr, f"select_bias must be non-increasing in length, got {biases}" + + +def test_negative_length_uses_absolute_value_for_bias(): + """Negative dimension values (e.g. inverted angles) clamp to abs() for hit-box scaling; + select_bias follows the same clamped magnitude so signed-direction gizmos still + obey the smaller-wins rule against their positive-sided peers.""" + positive = SimpleNamespace() + negative = SimpleNamespace() + GizmoDimension.set_dimension_length(positive, 0.5) + GizmoDimension.set_dimension_length(negative, -0.5) + assert positive.select_bias == negative.select_bias + + +def test_nan_and_inf_length_falls_back_to_zero_bias(): + """Invalid inputs are coerced to 0.0 before the bias is written, so a malformed + update can't push a gizmo arbitrarily far forward or backward in the select buffer.""" + import math + + for bad in (math.nan, math.inf, -math.inf, "not a number"): + gizmo = SimpleNamespace() + GizmoDimension.set_dimension_length(gizmo, bad) + assert gizmo.select_bias == 0.0 diff --git a/src/bonsai/test/bim/module/drawing/test_gizmos.py b/src/bonsai/test/bim/module/drawing/test_gizmos.py new file mode 100644 index 0000000000..cc781cd118 --- /dev/null +++ b/src/bonsai/test/bim/module/drawing/test_gizmos.py @@ -0,0 +1,54 @@ +# 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. + +import types +from types import SimpleNamespace + +import bpy +import pytest + +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig + +pytestmark = pytest.mark.drawing + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def test_text_formatter_defaults_to_none(): + config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0)) + assert config.text_formatter is None + + +def test_text_formatter_field_stores_callable(): + formatter = lambda props, value: f"{value:.2f}m" # noqa: E731 + config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter) + assert config.text_formatter is not None + assert callable(config.text_formatter) + + +def test_text_formatter_receives_props_and_value(): + formatter = lambda props, value: f"{props.label}={value}" # noqa: E731 + config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter) + props = SimpleNamespace(label="L") + assert config.text_formatter(props, 3.14) == "L=3.14" diff --git a/src/bonsai/test/bim/module/model/__init__.py b/src/bonsai/test/bim/module/model/__init__.py new file mode 100644 index 0000000000..023d474feb --- /dev/null +++ b/src/bonsai/test/bim/module/model/__init__.py @@ -0,0 +1,19 @@ +# 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. diff --git a/src/bonsai/test/bim/module/model/test_stair_gizmos.py b/src/bonsai/test/bim/module/model/test_stair_gizmos.py new file mode 100644 index 0000000000..060fdbcbe5 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_stair_gizmos.py @@ -0,0 +1,135 @@ +# 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. + +"""Regression guard for the stair icon billboard fix. + +Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos`` +composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the +stair's world rotation on top of the billboard rotation. The result was +icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on +to the camera for any stair rotated in plan — effectively unclickable. + +The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``, +which computes ``Translation(world_pos) @ billboard_rot @ Scale`` — the +object's rotation is folded into the translation only, never the rotation.""" + +import math +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def _rotation_close(a, b, tol: float = 1e-6) -> bool: + for row_a, row_b in zip(a, b): + for va, vb in zip(row_a, row_b): + if abs(va - vb) > tol: + return False + return True + + +@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217]) +def test_billboarded_at_rotation_is_pure_billboard(angle_deg): + """Object rotation must not leak into the gizmo's rotation part.""" + from mathutils import Matrix, Vector + + from bonsai.bim.module.drawing.gizmos import billboarded_at + + mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5)) + billboard_rot = Matrix.Rotation(math.radians(30), 4, "X") + + world_pos = mw @ Vector((1, 0, 2)) + result = billboarded_at(world_pos, billboard_rot, scale=0.5) + + # The rotation part of result, after stripping the 0.5 uniform scale, + # must equal billboard_rot — no contribution from mw's rotation. + rotation_part = result.to_3x3() * 2.0 + assert _rotation_close(rotation_part.to_4x4(), billboard_rot) + + +def test_billboarded_at_translation_is_world_pos(): + """Translation lands exactly at the world-space target.""" + from mathutils import Matrix, Vector + + from bonsai.bim.module.drawing.gizmos import billboarded_at + + world_pos = Vector((1.23, 4.56, 7.89)) + result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5) + assert (result.translation - world_pos).length < 1e-6 + + +def test_set_icon_gizmo_position_does_not_apply_object_rotation(): + """End-to-end: the helper used by every stair icon (and shared with all + parametric gizmo groups) must produce a matrix whose rotation part is + billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug + that left stair icons edge-on to the camera.""" + from mathutils import Matrix, Vector + + from bonsai.bim.module.drawing.gizmos import ( + BaseParametricGizmoGroup, + billboarded_at, + ) + + # Same inputs as the real call site (stair.py:747-765), but we drive the + # helper directly so we don't need a registered GizmoGroup. We bind a + # stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's + # observable output is the matrix_basis it assigns. + captured = {} + + class _GizmoStub: + matrix_basis = Matrix.Identity(4) + + stub = _GizmoStub() + + def _fake_get(name): + captured["name"] = name + return stub + + # Bind the helper to a throwaway instance so `self.get_gizmo_if_visible` + # resolves to our stub without registering a real GizmoGroup with Blender. + fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get) + mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5)) + billboard_rot = Matrix.Rotation(math.radians(30), 4, "X") + local_pos = Vector((1, 0, 2)) + BaseParametricGizmoGroup.set_icon_gizmo_position( + fake_self, + "validate_gizmo", + mw=mw, + x=local_pos.x, + y=local_pos.y, + z=local_pos.z, + billboard_rot=billboard_rot, + scale=0.5, + ) + + expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5) + + assert captured["name"] == "validate_gizmo" + for row_a, row_b in zip(stub.matrix_basis, expected): + for va, vb in zip(row_a, row_b): + assert abs(va - vb) < 1e-6 diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_gizmos.py new file mode 100644 index 0000000000..3fd5699ef2 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos.py @@ -0,0 +1,181 @@ +# 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 poll() preconditions of wall billboarding gizmo groups. + +These tests patch ``tool.Blender`` / ``tool.Ifc`` / ``tool.Model`` so the poll +logic can be exercised without a real IFC fixture. Each test pins one of the +gates ``poll()`` walks, so any silent regression in the gate order or in the +LAYER3-active / LAYER2-other contract is caught by a dedicated assertion.""" + +import types +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import pytest + +pytestmark = pytest.mark.wall + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def _make_context(active, selected): + """Build a minimal ``context`` stub with the two attributes ``poll()`` reads.""" + return SimpleNamespace(active_object=active, selected_objects=list(selected)) + + +def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage): + """Return a stack of patches that simulate one selection / IFC state for poll(). + + ``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The + selection set, the IFC entity lookup, and the usage-type lookup are stubbed + so the test only depends on the predicate ordering in poll().""" + prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on)) + + entity_map = {} + usage_map = {} + # active_element/other_element are matched by object identity from the selected set + if len(selected) == 2: + entity_map[id(selected[0])] = active_element + entity_map[id(selected[1])] = other_element + usage_map[id(active_element)] = active_usage + usage_map[id(other_element)] = other_usage + + def get_entity(obj): + return entity_map.get(id(obj)) + + def get_usage_type(element): + return usage_map.get(id(element)) + + from bonsai import tool + + return [ + patch.object(tool.Blender, "get_addon_preferences", return_value=prefs), + patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)), + patch.object(tool.Ifc, "get_entity", side_effect=get_entity), + patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type), + ] + + +def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True): + from bonsai.bim.module.model.wall import GizmoWallExtendVertically + + slab_obj = object() + wall_obj = object() + active = slab_obj if active_is_in_selected else object() + if len_override is None: + selected = [slab_obj, wall_obj] + else: + selected = [object() for _ in range(len_override)] + if active_is_in_selected and selected: + active = selected[0] + + slab_element = object() if active_has_entity else None + wall_element = object() + + patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage) + for p in patches: + p.start() + try: + return GizmoWallExtendVertically.poll(_make_context(active, selected)) + finally: + for p in patches: + p.stop() + + +def test_poll_accepts_layer3_active_with_layer2_other(): + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2" + ) + is True + ) + + +def test_poll_rejects_when_gizmo_toggle_off(): + assert ( + _run_poll( + prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2" + ) + is False + ) + + +def test_poll_rejects_when_selection_count_is_not_two(): + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2" + ) + is False + ) + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2" + ) + is False + ) + + +def test_poll_rejects_when_active_has_no_ifc_entity(): + assert ( + _run_poll( + prefs_on=True, + active_is_in_selected=True, + len_override=None, + active_usage="LAYER3", + other_usage="LAYER2", + active_has_entity=False, + ) + is False + ) + + +def test_poll_rejects_when_active_is_not_layer3(): + # A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo + # owns that case, and extend_walls_to_underside expects the slab to be active. + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2" + ) + is False + ) + # Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected. + assert ( + _run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2") + is False + ) + + +def test_poll_rejects_when_other_is_not_layer2_wall(): + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3" + ) + is False + ) + assert ( + _run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None) + is False + ) diff --git a/src/bonsai/test/bim/module/model/test_wall_header_refresh.py b/src/bonsai/test/bim/module/model/test_wall_header_refresh.py new file mode 100644 index 0000000000..933fab2454 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_header_refresh.py @@ -0,0 +1,87 @@ +# 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. + +"""Regression tests for the post-IFC-commit refresh path that re-syncs the +workspace tool header (``BIMModelProperties``) and invalidates the per-wall +gizmo geometry cache. + +Bug repro before the fix: hotkey operators that edited the active wall in +place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never +fired ``active_object_callback`` (no selection change), so the header H/L/A +fields and the gizmo cache both kept showing stale values. ``refresh_ui_data`` +ran, but it never resynced ``BIMModelProperties`` and never invalidated the +per-gizmo-group geometry cache. The fix wires both refreshes through +``tool.Parametric.refresh_post_commit`` and calls it from every +``tool.Ifc.Operator`` epilogue.""" + +import types +from unittest.mock import patch + +import bpy +import pytest + +pytestmark = pytest.mark.wall + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def test_refresh_post_commit_bumps_generation_and_resyncs_header(): + """``refresh_post_commit`` must bump the generation counter and call + ``update_bim_tool_props`` so the workspace tool header re-syncs from IFC.""" + import bonsai.bim.handler as handler + from bonsai import tool + + before = tool.Parametric.get_geom_generation() + with patch.object(handler, "update_bim_tool_props") as mock_resync: + tool.Parametric.refresh_post_commit() + assert tool.Parametric.get_geom_generation() == before + 1 + mock_resync.assert_called_once() + + +def test_geom_generation_invalidates_wall_geom_cache(): + """Bumping the generation must cause ``_get_wall_geom_cached`` to drop its + stored entries on the next read, even when the same gizmo group instance + and the same wall object are reused (the case Blender's + ``GizmoGroup.refresh()`` does not cover).""" + from bonsai import tool + from bonsai.bim.module.model import wall as wall_mod + + class _FakeGroup: + pass + + group = _FakeGroup() + fake_obj = types.SimpleNamespace(name="Wall/W001") + sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0} + sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0} + + with patch.object(wall_mod, "_read_wall_geometry", side_effect=[sentinel_a, sentinel_b]): + first = wall_mod._get_wall_geom_cached(group, fake_obj) + assert first is sentinel_a + # Same call without a generation bump must hit the cache (no extra read). + assert wall_mod._get_wall_geom_cached(group, fake_obj) is sentinel_a + # Simulate an IFC commit: generation advances, cache must drop. + tool.Parametric._geom_generation += 1 + second = wall_mod._get_wall_geom_cached(group, fake_obj) + assert second is sentinel_b + assert second is not first diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 79e8494441..348fa7f899 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations @@ -1131,6 +1133,17 @@ def the_variable_key_is_value(key, value): variables[key] = eval(replace_variables(value)) +@then(parsers.parse('the variable "{key}" equals "{value}"')) +def the_variable_key_equals_value(key, value): + assert key in variables, f'Variable "{key}" was never set' + expected = eval(replace_variables(value)) + actual = variables[key] + if isinstance(actual, float) and isinstance(expected, float): + assert abs(actual - expected) < 1e-5, f'Variable "{key}" is {actual!r}, expected {expected!r}' + else: + assert actual == expected, f'Variable "{key}" is {actual!r}, expected {expected!r}' + + @then("nothing happens") def nothing_happens(): pass diff --git a/src/bonsai/test/bim/test_parametric_lifecycle.py b/src/bonsai/test/bim/test_parametric_lifecycle.py new file mode 100644 index 0000000000..4142f51e63 --- /dev/null +++ b/src/bonsai/test/bim/test_parametric_lifecycle.py @@ -0,0 +1,409 @@ +# 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 coverage for the shared parametric-edit lifecycle mixins. + +``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6 +parametric features (door, window, railing, roof). The registry smoke test +elsewhere verifies operators are wired up; the mixins' own state-transition +contracts are tested here. + +The mixins are exercised through minimal in-test subclasses that supply the +abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and +``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are +patched at the module attribute (not the source module) so each test sees +isolated mock state.""" + +import json +from typing import ClassVar +from unittest import mock + +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + import types as _types + + import bpy + + if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +class _FakeProps: + """Stand-in for ``BIMProperties`` — records what was set so tests can + assert state transitions without instantiating real PropertyGroups.""" + + def __init__(self): + self.is_editing = False + self.last_kwargs = None + self.general = {"width": 1000} + self.lining = {"thickness": 50} + self.panel = {"material": "wood"} + + def set_props_kwargs_from_ifc_data(self, data): + self.last_kwargs = dict(data) + + def get_general_kwargs(self, convert_to_project_units=True): + return dict(self.general) + + def get_lining_kwargs(self, convert_to_project_units=True): + return dict(self.lining) + + def get_panel_kwargs(self, convert_to_project_units=True): + return dict(self.panel) + + +def _make_obj(props): + obj = mock.Mock() + obj.props = props + obj.name = "TestObj" + return obj + + +def _make_pset_text(general, lining, panel): + payload = {"lining_properties": lining, "panel_properties": panel, **general} + return json.dumps(payload) + + +# ---------------------------------------------------------------------- +# FeatureModifierEditMixin (door/window pattern) +# ---------------------------------------------------------------------- + + +def _door_mixin_cls(match=True, raise_on_update=False): + from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin + + raised = raise_on_update + + class _TestDoorMixin(FeatureModifierEditMixin): + pset_name: ClassVar[str] = "BBIM_Door" + representations_called: ClassVar[list] = [] + + @classmethod + def _is_element_type(cls, element): + return match + + @classmethod + def _get_props(cls, obj): + return obj.props + + @classmethod + def _update_modifier_representation(cls, obj, context): + cls.representations_called.append(obj) + if raised: + raise RuntimeError("simulated representation failure") + + return _TestDoorMixin + + +@pytest.fixture +def patched_tool_and_ifc(): + """Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module. + + Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset, + mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return + values and assert call args.""" + target = "bonsai.bim.parametric_lifecycle" + with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch( + f"{target}.bonsai" + ) as mock_bonsai: + # Element returned by tool.Ifc.get_entity is reused across mocks. + element = mock.Mock(name="entity") + mock_tool.Ifc.get_entity.return_value = element + mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file") + mock_tool.Model.get_constituents_props_data.return_value = {"materials": []} + mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset") + mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark + yield { + "tool": mock_tool, + "ifc": mock_ifc, + "bonsai": mock_bonsai, + "element": element, + } + + +def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc): + props = _FakeProps() + obj = _make_obj(props) + patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text( + {"width": 1234}, {"thickness": 50}, {"material": "wood"} + ) + + cls = _door_mixin_cls(match=True) + cls._enable_one(obj) + + assert props.is_editing is True + assert props.last_kwargs is not None + assert props.last_kwargs["width"] == 1234 + assert props.last_kwargs["thickness"] == 50 + assert props.last_kwargs["material"] == "wood" + assert "materials" in props.last_kwargs # from get_constituents_props_data + + +def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc): + props = _FakeProps() + obj = _make_obj(props) + + cls = _door_mixin_cls(match=False) + cls._enable_one(obj) + + assert props.is_editing is False + assert props.last_kwargs is None + # get_pset must not be called when _is_element_type returns False — the + # _resolve guard short-circuits before reading pset data. + patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called() + + +def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc): + """tool.Ifc.get_entity returning None must short-circuit before predicate runs.""" + props = _FakeProps() + obj = _make_obj(props) + patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None + + cls = _door_mixin_cls(match=True) + cls._enable_one(obj) + + assert props.is_editing is False + + +def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc): + props = _FakeProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + + cls = _door_mixin_cls(match=True) + cls._finish_one(obj, ctx) + + assert props.is_editing is False + assert obj in cls.representations_called + # edit_pset is called exactly once; properties key is "Data" wrapping JSON. + patched_tool_and_ifc["ifc"].api.pset.edit_pset.assert_called_once() + kwargs = patched_tool_and_ifc["ifc"].api.pset.edit_pset.call_args.kwargs + assert "properties" in kwargs and "Data" in kwargs["properties"] + + +def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc): + """If _update_modifier_representation raises, is_editing must stay True + so the user's draft survives for retry. This is the contract called out + in parametric_lifecycle.py:161 — set is_editing=False only on success.""" + props = _FakeProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + + cls = _door_mixin_cls(match=True, raise_on_update=True) + with pytest.raises(RuntimeError, match="simulated representation failure"): + cls._finish_one(obj, ctx) + + assert props.is_editing is True # draft survives + + +def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc): + props = _FakeProps() + props.is_editing = True + obj = _make_obj(props) + patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text( + {"width": 900}, {"thickness": 60}, {"material": "steel"} + ) + + cls = _door_mixin_cls(match=True) + cls._cancel_one(obj) + + assert props.is_editing is False + assert props.last_kwargs is not None and props.last_kwargs["width"] == 900 + # switch_representation must be called via bonsai.core.geometry. + patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once() + + +def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc): + """_enable_targets / _finish_targets / _cancel_targets iterate + _iter_targets — default is [active_object]; subclasses can override.""" + props_a, props_b = _FakeProps(), _FakeProps() + obj_a, obj_b = _make_obj(props_a), _make_obj(props_b) + patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text( + {"width": 1000}, {"thickness": 50}, {"material": "wood"} + ) + + cls = _door_mixin_cls(match=True) + cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b]) + + result = cls()._enable_targets(mock.Mock()) + + assert result == {"FINISHED"} + assert props_a.is_editing is True + assert props_b.is_editing is True + + +# ---------------------------------------------------------------------- +# PathPreservingEditMixin (railing/roof pattern) +# ---------------------------------------------------------------------- + + +class _FakePathProps: + """Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel).""" + + def __init__(self): + self.is_editing = False + self.last_kwargs = None + self.general = {"width": 200, "thickness": 10} + + def set_props_kwargs_from_ifc_data(self, data): + self.last_kwargs = dict(data) + + def get_general_kwargs(self, convert_to_project_units=True): + return dict(self.general) + + +def _path_mixin_cls(match=True): + from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin + + class _TestPathMixin(PathPreservingEditMixin): + pset_name: ClassVar[str] = "BBIM_Railing" + pset_updates: ClassVar[list] = [] + ifc_data_updates: ClassVar[list] = [] + bmesh_updates: ClassVar[list] = [] + + @classmethod + def _is_element_type(cls, element): + return match + + @classmethod + def _get_props(cls, obj): + return obj.props + + @classmethod + def _update_pset(cls, element, data): + cls.pset_updates.append((element, data)) + + @classmethod + def _update_modifier_ifc_data(cls, obj, context): + cls.ifc_data_updates.append(obj) + + @classmethod + def _update_modifier_bmesh(cls, obj, context): + cls.bmesh_updates.append(obj) + + return _TestPathMixin + + +def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc): + props = _FakePathProps() + obj = _make_obj(props) + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}} + } + + cls = _path_mixin_cls(match=True) + cls._enable_one(obj) + + assert props.is_editing is True + assert props.last_kwargs is not None + assert props.last_kwargs["width"] == 250 + # path_data passes through (default _post_load_data is pass-through) + assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]} + + +def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc): + props = _FakePathProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]} + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"path_data": sentinel_path} + } + + cls = _path_mixin_cls(match=True) + cls._finish_one(obj, ctx) + + assert props.is_editing is False + assert cls.pset_updates, "_update_pset must be called on Finish" + assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference + assert obj in cls.ifc_data_updates + + +def test_path_preserving_cancel_one_calls_update_modifier_bmesh(patched_tool_and_ifc): + props = _FakePathProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"width": 250, "path_data": {"points": []}} + } + + cls = _path_mixin_cls(match=True) + cls._cancel_one(obj, ctx) + + assert props.is_editing is False + assert obj in cls.bmesh_updates + + +def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc): + """Railing overrides _post_load_data to JSON-serialise path_data — + confirm the hook is honoured (here we drop a sentinel key).""" + props = _FakePathProps() + obj = _make_obj(props) + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"width": 250, "extra": "drop_me"} + } + + cls = _path_mixin_cls(match=True) + cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"}) + cls._enable_one(obj) + + assert "extra" not in props.last_kwargs + + +# ---------------------------------------------------------------------- +# _ParametricEditMixinBase._resolve guard +# ---------------------------------------------------------------------- + + +def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc): + cls = _door_mixin_cls(match=True) + patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None + obj = _make_obj(_FakeProps()) + + assert cls._resolve(obj) is None + + +def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc): + cls = _door_mixin_cls(match=False) + obj = _make_obj(_FakeProps()) + + assert cls._resolve(obj) is None + + +def test_resolve_returns_tuple_when_match(patched_tool_and_ifc): + cls = _door_mixin_cls(match=True) + props = _FakeProps() + obj = _make_obj(props) + + resolved = cls._resolve(obj) + + assert resolved is not None + element, returned_props = resolved + assert element is patched_tool_and_ifc["element"] + assert returned_props is props diff --git a/src/bonsai/test/bim/test_parametric_registry.py b/src/bonsai/test/bim/test_parametric_registry.py new file mode 100644 index 0000000000..ec4383dccb --- /dev/null +++ b/src/bonsai/test/bim/test_parametric_registry.py @@ -0,0 +1,157 @@ +# 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. + +"""Registration smoke test for `tool.Parametric.EDIT_TYPES`. + +The registry is the single source of truth for which parametric element types +exist. Every consumer (auto-commit on save, finish/cancel chains, the +``PointerProperty`` attachment, the ``GizmoPreferences`` registration) derives +identifiers from each entry's short ``name`` token. Forget any downstream +registration and the silent-desync the framework exists to prevent will ship. + +These tests pin the registry-to-runtime contract: for every entry the operator +``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the +``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type +predicate exists on `tool.Blender.Modifier`.""" + +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +@pytest.fixture +def registry(): + from bonsai import tool + + return tool.Parametric.EDIT_TYPES + + +def test_registry_is_non_empty(registry): + assert len(registry) >= 1 + + +def test_every_entry_has_enable_op_registered(registry): + missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))] + assert not missing, f"Missing enable operators: {missing}" + + +def test_every_entry_has_finish_op_registered(registry): + missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))] + assert not missing, f"Missing finish operators: {missing}" + + +def test_every_entry_has_cancel_op_registered(registry): + missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))] + assert not missing, f"Missing cancel operators: {missing}" + + +def test_every_entry_has_property_group_attached(registry): + # ``register_object_properties`` runs at addon enable; if any entry's + # PropertyGroup class is missing on prop module the attribute is skipped. + missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)] + assert not missing, ( + f"bpy.types.Object missing attributes: {missing} — " + f"verify the matching PropertyGroup classes exist in bim.module.model.prop" + ) + + +def test_every_entry_has_modifier_predicate(registry): + from bonsai import tool + + missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None] + assert not missing, f"tool.Blender.Modifier missing is_ predicates: {missing}" + + +def test_every_predicate_does_not_raise_on_non_matching_element(registry): + """Each ``is_`` predicate must be **total**: accept any IFC entity + and return a truthy/falsy value, never raise. + + The registry iterates every predicate against the active IFC element on + save; a raising predicate (e.g. ``AttributeError`` from a missing pset + accessor when handed a non-matching element type) propagates upward and + breaks the save path for *all* parametric types, not just its own. + This test probes each predicate with an ``IfcAnnotation`` (an element + that carries none of the BBIM_ psets the predicates look up) and + asserts the call does not raise. Falsy returns are acceptable — the + registry treats them as 'no match'. What's forbidden is raising.""" + import ifcopenshell + + from bonsai import tool + + probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation") + + raised = [] + for feature in registry: + predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None) + if predicate is None: + continue + try: + predicate(probe) + except Exception as e: + raised.append((feature.name, type(e).__name__, str(e))) + assert not raised, ( + f"is_ predicates raised on a non-matching IfcAnnotation: {raised}. " + f"Predicates must be total — return bool, never raise. Add an " + f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup." + ) + + +def test_gizmo_preferences_attached_when_class_exists(registry): + """For every registry entry whose ``GizmoPreferences`` class exists in + ``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on + ``ui.GizmoPreferences`` under the registry entry's ``name`` token. + + Catches the silent-skip behaviour of the registry-driven gizmo-prefs + discovery: a typo in the class name or a dropped registration would + otherwise produce a missing sub-panel at runtime with no error. + Entries without a ``GizmoPreferences`` class are allowed — not + every parametric type ships gizmo prefs. + + Checks ``__annotations__`` rather than ``hasattr`` because Blender's + PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an + annotation-only assignment — the attribute only materialises on the + class after Blender's metaclass installs the bpy_struct descriptor, + which depends on registration timing. Reading ``__annotations__`` + pins the source-level contract independently of when register() ran.""" + from bonsai.bim import ui + + annotations = getattr(ui.GizmoPreferences, "__annotations__", {}) + missing = [] + for feature in registry: + prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}" + if not hasattr(ui, prefs_class_name): + continue + if feature.name not in annotations: + missing.append((feature.name, prefs_class_name)) + assert not missing, ( + f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing} — " + f"each registered ``GizmoPreferences`` class must have a matching " + f"``: PointerProperty(type=GizmoPreferences)`` field on " + f"``ui.GizmoPreferences``" + ) diff --git a/src/bonsai/test/core/test_model.py b/src/bonsai/test/core/test_model.py new file mode 100644 index 0000000000..fe6e9903b6 --- /dev/null +++ b/src/bonsai/test/core/test_model.py @@ -0,0 +1,243 @@ +# 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. + +"""Tests for pure-Python math helpers in bonsai.core.model used by the wall gizmo system. + +These run in the core lane (``pytest test/core/``) — no Blender, no IFC file. The +helpers under test live in ``bonsai/core/model.py`` and are deliberately pure (tuple +in, tuple out) so they're exercisable without ``mathutils`` or ``bpy``.""" + +import math + +import pytest + +import bonsai.core.model as subject + + +class TestBaselineFromOffset: + THICKNESS = 0.2 + + def test_positive_direction_exterior(self): + assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR" + + def test_positive_direction_center(self): + assert subject.baseline_from_offset(-self.THICKNESS / 2, self.THICKNESS) == "CENTER" + + def test_positive_direction_interior(self): + assert subject.baseline_from_offset(-self.THICKNESS, self.THICKNESS) == "INTERIOR" + + def test_negative_direction_exterior(self): + assert subject.baseline_from_offset(self.THICKNESS, self.THICKNESS) == "EXTERIOR" + + def test_negative_direction_center(self): + assert subject.baseline_from_offset(self.THICKNESS / 2, self.THICKNESS) == "CENTER" + + def test_negative_direction_interior(self): + assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR" + + def test_within_tolerance_still_matches(self): + # A 0.5mm jitter on a 200mm wall should still classify cleanly. + assert subject.baseline_from_offset(-self.THICKNESS / 2 + 0.0005, self.THICKNESS) == "CENTER" + + def test_outside_tolerance_falls_back_to_center(self): + # 50mm offset on a 200mm wall — not a canonical position. + assert subject.baseline_from_offset(0.05, self.THICKNESS) == "CENTER" + + +class TestProjectAxisIntersection: + PARALLEL_THRESHOLD = 0.9994 # cos(2°) + + def test_perpendicular_walls_meet_at_corner(self): + # Wall A along +X from origin; wall B along +Y from (5, 0, 0). + # Axes meet exactly at (5, 0). + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0)) + result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) + assert result is not None + assert result[0] == pytest.approx(5.0) + assert result[1] == pytest.approx(0.0) + + def test_offset_walls_intersect_at_extrapolated_point(self): + # Wall A: y=0 from x=1 to x=6. + # Wall B: x=0 from y=1 to y=4. + # Infinite-line intersection at (0, 0). + seg_a = ((1.0, 0.0, 0.0), (6.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (0.0, 4.0, 0.0)) + result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) + assert result is not None + assert result[0] == pytest.approx(0.0) + assert result[1] == pytest.approx(0.0) + + def test_parallel_walls_return_none(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0)) + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_anti_parallel_walls_return_none(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 1.0, 0.0), (0.0, 1.0, 0.0)) # opposite direction + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_nearly_parallel_walls_return_none(self): + # 1° off parallel — within the ~2° dead-band. + angle = math.radians(1) + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (5.0 * math.cos(angle), 1.0 + 5.0 * math.sin(angle), 0.0)) + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_zero_length_segment_returns_none(self): + seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)) + seg_b = ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)) + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_intersection_z_is_average_of_endpoint_zs(self): + # Walls at different elevations; the icon-placement Z should be the average. + seg_a = ((0.0, 0.0, 1.0), (5.0, 0.0, 1.0)) # at z=1 + seg_b = ((5.0, 0.0, 3.0), (5.0, 3.0, 3.0)) # at z=3 + result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) + assert result is not None + assert result[2] == pytest.approx(2.0) + + +class TestSlopeRoundTrip: + def test_zero_angle_zero_displacement(self): + assert subject.displacement_from_x_angle(3.0, 0.0) == pytest.approx(0.0) + assert subject.x_angle_from_displacement(3.0, 0.0) == pytest.approx(0.0) + + def test_positive_angle_positive_displacement(self): + # 30° slope on a 3m wall → top moves ~1.732m in +Y. + displacement = subject.displacement_from_x_angle(3.0, math.radians(30)) + assert displacement == pytest.approx(3.0 * math.tan(math.radians(30))) + + def test_negative_angle_negative_displacement(self): + displacement = subject.displacement_from_x_angle(3.0, math.radians(-15)) + assert displacement < 0 + + def test_round_trip_preserves_angle(self): + # Drag-to-angle-to-drag preserves the original. + original_angle = math.radians(20) + displacement = subject.displacement_from_x_angle(3.0, original_angle) + recovered = subject.x_angle_from_displacement(3.0, displacement) + assert recovered == pytest.approx(original_angle, abs=1e-9) + + def test_round_trip_handles_zero_height(self): + # Walls of effectively zero height should not divide-by-zero. + recovered = subject.x_angle_from_displacement(0.0, 1.0) + assert recovered == pytest.approx(math.pi / 2, abs=1e-3) + + +class TestAreAxesCollinear: + PARALLEL_THRESHOLD = 0.9994 + LINE_TOLERANCE = 0.05 + + def test_end_to_end_walls_along_x_are_collinear(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_separated_collinear_walls_with_gap(self): + # Walls with a 1m gap between them — still on the same line. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((6.0, 0.0, 0.0), (10.0, 0.0, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_perpendicular_walls_are_not_collinear(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 0.0, 0.0), (0.0, 5.0, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_parallel_walls_offset_perpendicular_are_not_collinear(self): + # Two parallel walls 1m apart — same direction but not the same line. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_anti_parallel_collinear_walls(self): + # Reversed direction on the same line still counts as collinear. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((10.0, 0.0, 0.0), (6.0, 0.0, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_z_is_ignored_for_plan_collinearity(self): + # Walls on different floors are still considered collinear in plan. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_zero_length_segment_is_not_collinear(self): + seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)) + seg_b = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_slightly_off_line_within_tolerance(self): + # 2cm perpendicular offset — still within the 5cm tolerance. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.02, 0.0), (10.0, 0.02, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_too_far_off_line_fails_tolerance(self): + # 10cm perpendicular offset — outside the 5cm tolerance. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.10, 0.0), (10.0, 0.10, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + +class TestClosestEndpointMidpoint: + def test_end_to_end_walls_midpoint_is_the_shared_corner(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0)) + + def test_walls_with_gap_midpoint_is_in_the_gap(self): + # Wall A ends at x=5; wall B starts at x=7. Boundary midpoint is at x=6. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((7.0, 0.0, 0.0), (12.0, 0.0, 0.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + assert result == (pytest.approx(6.0), pytest.approx(0.0), pytest.approx(0.0)) + + def test_perpendicular_walls_midpoint_is_between_nearest_endpoints(self): + # Wall A's +X endpoint (5,0,0) and wall B's origin (5,0,0) → midpoint at (5,0,0). + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0)) + + def test_z_averaged_when_walls_at_different_elevations(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + # Closest pair: (5,0,0) and (5,0,3); midpoint Z = 1.5. + assert result[2] == pytest.approx(1.5) + + +class TestVerticalHeightFromExtrusionDepth: + def test_vertical_wall_returns_depth_unchanged(self): + assert subject.vertical_height_from_extrusion_depth(3.0, 0.0) == pytest.approx(3.0) + + def test_30_degree_slope(self): + # cos(30°) ≈ 0.866 → vertical height of a 3m slanted extrusion ≈ 2.598m. + result = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30)) + assert result == pytest.approx(3.0 * math.cos(math.radians(30))) + + def test_negative_angle_yields_same_magnitude(self): + positive = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30)) + negative = subject.vertical_height_from_extrusion_depth(3.0, math.radians(-30)) + assert positive == pytest.approx(negative)