mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 11:20:21 +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:
@@ -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