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