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