From 233cc344fa12f7e2b8f1864f5ea6538452738dd8 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 20 May 2026 15:18:44 +0200 Subject: [PATCH] Add tool.Parametric registry and lifecycle mixins Establish a single source of truth for parametric element types (door, window, stair, railing, roof). tool.Parametric.EDIT_TYPES drives: - BIMProperties PointerProperty attachment via the registry - GizmoPreferences class registration in bim/__init__.py - save-time auto-commit of pending draft edits - the refresh_post_commit epilogue called from IfcStore after every IFC mutation, which fixes the stale-header bug where in-place hotkey mutations (S_E / C_E) left BIMModelProperties and the gizmo cache pointing at obsolete values. Refactors door/window/railing/roof onto shared mixins from bim/parametric_lifecycle.py (FeatureModifierEditMixin and PathPreservingEditMixin); stair gets the lock-gizmo refactor and frame-cache integration. Behavior preserved. Adds BaseParametricGizmoGroup._prime_frame_caches so the parametric gizmos stop re-deriving preferences, view direction, and billboard rotation per frame; reorders poll() to short-circuit on the cheapest predicate first. Adds the icon library + BillboardingGizmoGroupMixin that the wall feature in the next commit will consume. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/__init__.py | 21 +- src/bonsai/bonsai/bim/handler.py | 32 +- src/bonsai/bonsai/bim/ifc.py | 1 + .../bonsai/bim/module/drawing/__init__.py | 10 + .../bonsai/bim/module/drawing/gizmos.py | 441 ++++++++++++++--- .../bonsai/bim/module/model/__init__.py | 18 +- src/bonsai/bonsai/bim/module/model/door.py | 114 ++--- src/bonsai/bonsai/bim/module/model/railing.py | 94 ++-- src/bonsai/bonsai/bim/module/model/roof.py | 83 ++-- src/bonsai/bonsai/bim/module/model/stair.py | 37 +- src/bonsai/bonsai/bim/module/model/window.py | 94 ++-- .../bonsai/bim/module/project/operator.py | 65 ++- src/bonsai/bonsai/bim/parametric_lifecycle.py | 297 ++++++++++++ src/bonsai/bonsai/core/model.py | 189 ++++++++ src/bonsai/bonsai/core/tool.py | 6 + src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/blender.py | 80 ++-- src/bonsai/bonsai/tool/model.py | 7 + src/bonsai/bonsai/tool/parametric.py | 450 ++++++++++++++++++ .../test/bim/test_parametric_registry.py | 115 +++++ 20 files changed, 1780 insertions(+), 375 deletions(-) create mode 100644 src/bonsai/bonsai/bim/parametric_lifecycle.py create mode 100644 src/bonsai/bonsai/tool/parametric.py create mode 100644 src/bonsai/test/bim/test_parametric_registry.py diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 4302739aab..e3f26e1a5e 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]: + """Deferred lookup. Importing ``bonsai.tool`` at module top would cold-start + ``tool.blender`` → ``bim.ifc`` before the ``from . import handler, …`` above + has primed the ``bim.ifc`` ↔ ``bim.handler`` partial-import dance, crashing + addon registration. Resolved at classes-tuple build time below — by then the + relative imports have settled.""" + 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..2a35de3fcb 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 :class:`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 :class:`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: @@ -3913,6 +4162,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 :func:`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 +4431,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 :meth:`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 +4552,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 +4637,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 +4665,20 @@ class BaseParametricGizmoGroup: """ pass + # Frame-scoped caches populated by :meth:`_prime_frame_caches` 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 +4693,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 +4701,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 +4724,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. @@ -4594,28 +4934,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 :func:`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 +5020,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 +5048,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 +5082,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 +5098,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,7 +5106,7 @@ 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) + billboard_rot = self._frame_billboard_rot # This ensures icons face camera regardless of object rotation local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z)) @@ -4819,16 +5142,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 +5169,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..5e2f993404 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, @@ -264,12 +268,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 +290,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/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..30137a15d6 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,24 @@ 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 :py:meth:`_update_lock_gizmo_position`.""" + 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 in + :py:meth:`_update_lock_gizmo_position` (called per frame via + :py:meth:`_update_dimension_gizmo_positions`).""" 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 +647,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/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..92ff97d9b1 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 @@ -1873,6 +1875,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper): json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False) should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + confirm_parametric_edits: bpy.props.BoolProperty( + default=False, + options={"HIDDEN", "SKIP_SAVE"}, + description="Internal: routes draw() to the parametric-commit confirm body instead of the file dialog.", + ) if TYPE_CHECKING: filter_glob: str @@ -1880,6 +1887,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): json_compact: bool should_save_as: bool use_relative_path: bool + confirm_parametric_edits: bool @classmethod def poll(cls, context): @@ -1887,6 +1895,9 @@ class ExportIFC(bpy.types.Operator, ExportHelper): def draw(self, context): layout = self.layout + if self.confirm_parametric_edits: + self._draw_parametric_confirm(layout) + return layout.prop(self, "json_version") layout.prop(self, "json_compact") if bpy.data.is_saved: @@ -1896,6 +1907,33 @@ class ExportIFC(bpy.types.Operator, ExportHelper): layout.label(text="Supported formats for export:") layout.label(text=",".join(self.supported_filexts)) + def _draw_parametric_confirm(self, layout: bpy.types.UILayout) -> None: + col = layout.column(align=True) + col.label(text="Saving will commit all in-progress parametric edits to IFC") + col.label(text="before writing the file.") + layout.separator() + # Auto-derive the noun list from the parametric registry so the dialog stays + # in sync as new parametric element types are added. + nouns = [feature.name for feature in tool.Parametric.EDIT_TYPES] + if len(nouns) > 1: + noun_list = ", ".join(nouns[:-1]) + " or " + nouns[-1] + else: + noun_list = nouns[0] if nouns else "" + col = layout.column(align=True) + col.label(text="For example, if you are editing a parametric") + col.label(text=f"{noun_list}, all pending changes will be applied") + col.label(text="to the IFC file first.") + layout.separator() + col = layout.column(align=True) + col.label(text='Click "Commit & Save" to apply the pending edits and save,') + col.label(text="or press Esc to abort the save.") + layout.separator() + box = layout.box() + col = box.column(align=True) + col.label(text="To disable this prompt and always auto-commit silently,", icon="INFO") + col.label(text='turn off "Confirm Before Auto-Committing Parametric Edits') + col.label(text='on Save" in the Bonsai add-on preferences.') + def invoke(self, context, event): if not tool.Ifc.get(): bpy.ops.wm.save_mainfile("INVOKE_DEFAULT") @@ -1903,11 +1941,24 @@ 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) + filepath = props.ifc_file + if not filepath or self.should_save_as: + return ExportHelper.invoke(self, context, event) - return ExportHelper.invoke(self, context, event) + self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath))) + prefs = tool.Blender.get_addon_preferences() + if prefs.prompt_auto_commit_parametric_edits and tool.Parametric.get_pending_edits(): + # `invoke_props_dialog` fires `execute()` on OK using current properties, + # so `self.filepath` must already be set above. The `confirm_parametric_edits` + # flag routes `draw()` to the multi-line confirm body instead of the file dialog. + self.confirm_parametric_edits = True + return context.window_manager.invoke_props_dialog( + self, + width=460, + title="Pending Parametric Edits", + confirm_text="Commit & Save", + ) + return self.execute(context) def check(self, context): # ExportHelper is automatically adjusting suffix to `filename_ext`. @@ -1933,6 +1984,12 @@ class ExportIFC(bpy.types.Operator, ExportHelper): return {"FINISHED"} def _execute(self, context): + _, failed_commits = tool.Parametric.commit_pending_edits() + 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") diff --git a/src/bonsai/bonsai/bim/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py new file mode 100644 index 0000000000..dcfeb82b38 --- /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/``: + +:class:`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``. + +:class:`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 :class:`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``. Subclass override + :meth:`_post_load_data` 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/core/model.py b/src/bonsai/bonsai/core/model.py index e975505381..81fd25d109 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,189 @@ 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 segments lie on the same infinite line in plan (X,Y). + + Two conditions: their directions must be (anti-)parallel within + ``parallel_threshold`` (cos ~2°), AND any endpoint of B must lie on A's + infinite line within ``line_tolerance`` (~5cm). Z is ignored — two parallel + walls at different elevations are still considered collinear.""" + p1, p2 = seg_a + q1, q2 = seg_b + d1x, d1y = p2[0] - p1[0], p2[1] - p1[1] + d2x, d2y = q2[0] - q1[0], q2[1] - q1[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 + dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len) + if abs(dot) < parallel_threshold: + return False + # Project q1 onto the infinite line through seg_a; perpendicular distance + # from q1 to its projection tells us how far off the line B sits. + ux, uy = d1x / d1_len, d1y / d1_len + rx, ry = q1[0] - p1[0], q1[1] - p1[1] + t = rx * ux + ry * uy + proj_x = p1[0] + t * ux + proj_y = p1[1] + t * uy + perp_dist = ((q1[0] - proj_x) ** 2 + (q1[1] - proj_y) ** 2) ** 0.5 + return perp_dist < 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 is the midpoint of the gap. Either way it is the user-meaningful + "boundary" where a merge would graft the two segments together.""" + pairs = ((a, b) for a in seg_a for b in seg_b) + pa, pb = min(pairs, key=lambda pair: sum((pair[0][i] - pair[1][i]) ** 2 for i in range(3))) + return ((pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) / 2) + + +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..720e8f1839 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,18 @@ 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 + # Type-only — imported lazily to avoid a circular load when ``bim/__init__.py`` + # imports ``bonsai.tool`` before ``bim.ifc`` has reached its line-43 definition + # of ``IFC_CONNECTED_TYPE`` (the chain re-enters ``bim.ifc`` through + # ``bim.handler`` and trips on a still-undefined ``IfcStore``). The file has + # ``from __future__ import annotations``, so the type hint at line 1884 is a + # deferred string and needs no runtime binding. + 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 +1145,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 +1167,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 +1220,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 +1241,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..01ec51e7db --- /dev/null +++ b/src/bonsai/bonsai/tool/parametric.py @@ -0,0 +1,450 @@ +# 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 +:attr:`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 :attr:`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 + :attr:`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 — + :meth:`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 :mod:`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 + :attr:`ParametricObject.name` — :meth:`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`` — :meth:`Parametric.iter_gizmo_preference_classes` + discovers it from the registry automatically by its name + (``GizmoPreferences`` + capitalised registry token). + +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 the registration smoke test in + ``test/bim/test_parametric_registry.py`` — it iterates + :attr:`Parametric.EDIT_TYPES` and asserts each ``enable_op`` / + ``finish_op`` / ``cancel_op`` resolves to a registered operator, that + ``bpy.types.Object`` carries the matching ``BIMProperties`` + attribute, and that ``tool.Blender.Modifier.is_`` exists. 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 — + multi-word IFC types (e.g. ``IfcCurtainWall``) would silently mis-derive + through ``str.capitalize()`` and need a different approach than + appending to :data:`Parametric.EDIT_TYPES` directly. + + ``has_non_editable_path`` flags element types whose modifier exposes no + user-editable path (door, window, stair) — historically queried via + ``tool.Blender.Modifier.is_modifier_with_non_editable_path``.""" + + 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"), + ] + + _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: only use with operators that are themselves + ``tool.Ifc.Operator`` subclasses — their 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.""" + getattr(bpy.ops.bim, bl_idname.removeprefix("bim."))() + + @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 :meth:`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 :attr:`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/test/bim/test_parametric_registry.py b/src/bonsai/test/bim/test_parametric_registry.py new file mode 100644 index 0000000000..f5d3dac5a1 --- /dev/null +++ b/src/bonsai/test/bim/test_parametric_registry.py @@ -0,0 +1,115 @@ +# 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 :attr:`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 :class:`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_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 attached to + ``ui.GizmoPreferences`` under the registry entry's ``name`` token. + + Catches the silent-skip behaviour of + ``Parametric.iter_gizmo_preference_classes``: 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.""" + from bonsai.bim import ui + + missing = [] + for feature in registry: + prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}" + if not hasattr(ui, prefs_class_name): + continue + if not hasattr(ui.GizmoPreferences, feature.name): + 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``" + )