mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 10:06:47 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+127
-31
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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:")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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"
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
)
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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)
|
||||
Reference in New Issue
Block a user