diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py
index 5e2f993404..26dca1984d 100644
--- a/src/bonsai/bonsai/bim/module/model/__init__.py
+++ b/src/bonsai/bonsai/bim/module/model/__init__.py
@@ -74,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,
@@ -144,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,
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/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..da281897c8 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,1075 @@ 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
+
+ # 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 length_changed or height_changed or x_angle_changed or baseline_changed:
+ 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
+ ICON_TOGGLE_OPENINGS_X = 1.61
+
+ # 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.
+
+ Uses the manual ``Translation(world_pos) @ billboard_rot @ Scale`` pattern
+ rather than the base class's ``set_icon_gizmo_position`` helper. The helper
+ computes ``mw @ (Translation @ billboard_rot @ Scale)``, which applies the
+ wall's rotation to the billboard — for a wall rotated in plan, the icons
+ end up tilted edge-on to the camera instead of facing it. The base class's
+ own ``update_editing_gizmos`` already uses the manual pattern for validate/
+ cancel/cycle for exactly this reason; we match it here."""
+ 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 :meth:`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 :class:`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
+ :meth:`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 :func:`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 :func:`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 :class:`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 :class:`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/ui.py b/src/bonsai/bonsai/bim/ui.py
index 97980d92dd..885ad6749f 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):
@@ -664,6 +738,19 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
should_disable_undo_on_save: BoolProperty(
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
+ prompt_auto_commit_parametric_edits: BoolProperty(
+ name="Confirm Before Auto-Committing Parametric Edits on Save",
+ description=(
+ "When saving while a door/window/stair/railing/roof/wall edit is in progress, "
+ "show a confirmation dialog. Saving always commits the edit; this preference "
+ "only controls whether you are warned first. "
+ "Save As bypasses the prompt because the file picker is itself a dialog — "
+ "commits then happen silently. "
+ "Each committed edit is a separate undo step; saving with N edits in progress "
+ "produces N undo entries (one per commit) plus one for the save itself."
+ ),
+ default=True,
+ )
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
should_always_cache: BoolProperty(
name="Always Cache Geometry",
@@ -776,6 +863,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_load_test_dictionaries: bool
bsdd_baseurl: str
should_disable_undo_on_save: bool
+ prompt_auto_commit_parametric_edits: bool
should_stream: bool
should_always_cache: bool
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
@@ -849,49 +937,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")
@@ -975,6 +1070,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "opening_focus_opacity")
layout.prop(self, "should_disable_undo_on_save")
+ layout.prop(self, "prompt_auto_commit_parametric_edits")
layout.prop(self, "should_stream")
layout.prop(self, "should_always_cache")
layout.label(text="bSDD:")
diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py
index 01ec51e7db..8a2fe75279 100644
--- a/src/bonsai/bonsai/tool/parametric.py
+++ b/src/bonsai/bonsai/tool/parametric.py
@@ -272,6 +272,7 @@ class Parametric(bonsai.core.tool.Parametric):
ParametricObject("stair", has_non_editable_path=True),
ParametricObject("railing"),
ParametricObject("roof"),
+ ParametricObject("wall"),
]
_geom_generation: int = 0
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/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_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_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/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)