From 26eef20eb5180847357535b7ce934ecfe85f5cce Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 20 May 2026 16:58:39 +0200 Subject: [PATCH] Add wall parametric editing and gizmos Walls gain in-viewport parametric editing matching the door/window/stair UX: drag handles for length, height, slope (x-angle), layer baseline cycle, plus cursor-anchored quality-of-life operators (split at cursor, extend to cursor, extend height, rotate 90, toggle openings) and two-object state-machine gizmos (unjoin / merge / join-corner / extend-to-wall / extend-vertically / add-opening). Wall enters tool.Parametric.EDIT_TYPES, so save-time auto-commit, GizmoPreferencesWall registration, and the in-progress-edit predicates all light up automatically through the registry plumbing landed two commits back. The three-layer commit model (drag -> BIMWallProperties -> bmesh preview -> Finish -> single ifc.run) means dragging a handle through hundreds of intermediate values produces zero extra IFC entities. A no-op enable->finish round-trip is byte-identical. The snapshot diff in FinishEditingWall skips unchanged params. _commit_active_wall_edit_if_any ensures cursor-anchored operators see committed geometry, not the draft preview box. Also lands the `prompt_auto_commit_parametric_edits` BoolProperty on BIM_ADDON_preferences (consumed by the auto-commit dialog landed in the framework commit) and refactors `draw_{door,window,stair}_gizmo_parameters` into a shared `_draw_parametric_gizmo_parameters` helper that the new `draw_wall_gizmo_parameters` reuses. This commit and the framework commit are stacked - the framework commit references the BoolProperty defined here, so they must land together. Tests cover pure math (core/test_model.py), DimensionGizmoConfig text formatter, GizmoWallExtendVertically.poll() preconditions, and the refresh_post_commit cache-invalidation regression. BDD scenarios in model.feature cover the edit triad, auto-commit on save, and the two-object gizmos. Documentation added to creating_walls.rst. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 16 + src/bonsai/bonsai/bim/module/model/prop.py | 140 ++ src/bonsai/bonsai/bim/module/model/ui.py | 30 + src/bonsai/bonsai/bim/module/model/wall.py | 1210 ++++++++++++++++- src/bonsai/bonsai/bim/ui.py | 158 ++- src/bonsai/bonsai/tool/parametric.py | 1 + .../basic_modeling/creating_walls.rst | 56 + src/bonsai/test/bim/feature/model.feature | 123 ++ .../test/bim/module/drawing/test_gizmos.py | 54 + src/bonsai/test/bim/module/model/__init__.py | 19 + .../test/bim/module/model/test_wall_gizmos.py | 181 +++ .../module/model/test_wall_header_refresh.py | 87 ++ src/bonsai/test/bim/test_feature.py | 13 + src/bonsai/test/core/test_model.py | 243 ++++ 14 files changed, 2297 insertions(+), 34 deletions(-) create mode 100644 src/bonsai/test/bim/module/drawing/test_gizmos.py create mode 100644 src/bonsai/test/bim/module/model/__init__.py create mode 100644 src/bonsai/test/bim/module/model/test_wall_gizmos.py create mode 100644 src/bonsai/test/bim/module/model/test_wall_header_refresh.py create mode 100644 src/bonsai/test/core/test_model.py 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)