diff --git a/.github/workflows/ci-ifcwrap-standalone.yml b/.github/workflows/ci-ifcwrap-standalone.yml
new file mode 100644
index 0000000000..9bd901791b
--- /dev/null
+++ b/.github/workflows/ci-ifcwrap-standalone.yml
@@ -0,0 +1,155 @@
+# This file was generated with the assistance of an AI coding tool.
+name: ci-ifcwrap-standalone
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - ".github/workflows/ci-ifcwrap-standalone.yml"
+ - "cmake/**"
+ - "src/ifcwrap/**"
+ - "src/ifcparse/**"
+ - "src/ifcgeom/**"
+ - "src/serializers/**"
+ - "src/ifcconvert/**"
+ - "src/ifcopenshell-python/**"
+ - "src/svgfill/**"
+ push:
+ paths:
+ - ".github/workflows/ci-ifcwrap-standalone.yml"
+ - "cmake/**"
+ - "src/ifcwrap/**"
+ - "src/ifcparse/**"
+ - "src/ifcgeom/**"
+ - "src/serializers/**"
+ - "src/ifcconvert/**"
+ - "src/ifcopenshell-python/**"
+ - "src/svgfill/**"
+
+env:
+ IFCOPENSHELL_PREFIX: ${{ github.workspace }}/ifcopenshell-install
+
+jobs:
+ build-ifcopenshell:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+
+ - name: Install C++ dependencies
+ run: |
+ sudo apt update
+ sudo apt-get install --no-install-recommends -y \
+ cmake \
+ gcc \
+ g++ \
+ libboost-date-time-dev \
+ libboost-filesystem-dev \
+ libboost-iostreams-dev \
+ libboost-program-options-dev \
+ libboost-regex-dev \
+ libboost-system-dev \
+ libboost-thread-dev \
+ libeigen3-dev \
+ libocct-data-exchange-dev \
+ libocct-draw-dev \
+ libocct-foundation-dev \
+ libocct-modeling-algorithms-dev \
+ libocct-modeling-data-dev \
+ libocct-ocaf-dev \
+ libocct-visualization-dev \
+ libpcre3-dev \
+ libtbb-dev \
+ libxml2-dev \
+ libxi-dev \
+ occt-misc \
+ tcl-dev \
+ tk-dev \
+ swig
+
+ - name: Configure minimal IfcOpenShell
+ run: |
+ cmake -S cmake -B build-ifcopenshell \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX="${IFCOPENSHELL_PREFIX}" \
+ -DCMAKE_PREFIX_PATH=/usr \
+ -DCMAKE_SYSTEM_PREFIX_PATH=/usr \
+ -DMINIMAL_BUILD=ON \
+ -DBUILD_IFCPYTHON=OFF \
+ "-DSCHEMA_VERSIONS=4x3_add2"
+
+ - name: Build and install minimal IfcOpenShell
+ run: |
+ cmake --build build-ifcopenshell --target install -j "$(nproc)"
+
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v6
+ with:
+ python-version: 3.11
+
+ - name: Install Python import dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install numpy typing_extensions
+
+ - name: Configure standalone IfcPython
+ run: |
+ PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
+ PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
+
+ cmake -S src/ifcwrap -B "build-ifcwrap-311" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
+ -DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
+ -DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
+ -DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
+ -DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
+
+ - name: Build and install standalone IfcPython
+ run: |
+ cmake --build "build-ifcwrap-311" --target install -j "$(nproc)"
+
+ - name: Import installed IfcPython
+ run: |
+ PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
+ import ifcopenshell
+
+ print("IfcOpenShell import ok:", ifcopenshell.version)
+ PY
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v6
+ with:
+ python-version: 3.12
+
+ - name: Install Python import dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install numpy typing_extensions
+
+ - name: Configure standalone IfcPython
+ run: |
+ PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
+ PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
+
+ cmake -S src/ifcwrap -B "build-ifcwrap-312" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
+ -DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
+ -DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
+ -DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
+ -DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
+
+ - name: Build and install standalone IfcPython
+ run: |
+ cmake --build "build-ifcwrap-312" --target install -j "$(nproc)"
+
+ - name: Import installed IfcPython
+ run: |
+ PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
+ import ifcopenshell
+
+ print("IfcOpenShell import ok:", ifcopenshell.version)
+ PY
diff --git a/cmake/FindIfcOpenShell.cmake b/cmake/FindIfcOpenShell.cmake
new file mode 100644
index 0000000000..22e04ef5b8
--- /dev/null
+++ b/cmake/FindIfcOpenShell.cmake
@@ -0,0 +1,131 @@
+# This file was generated with the assistance of an AI coding tool.
+################################################################################
+# #
+# This file is part of IfcOpenShell. #
+# #
+# IfcOpenShell is free software: you can redistribute it and/or modify #
+# it under the terms of the Lesser GNU General Public License as published by #
+# the Free Software Foundation, either version 3.0 of the License, or #
+# (at your option) any later version. #
+# #
+# IfcOpenShell 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 #
+# Lesser GNU General Public License for more details. #
+# #
+# You should have received a copy of the Lesser GNU General Public License #
+# along with this program. If not, see . #
+# #
+################################################################################
+
+include("${CMAKE_CURRENT_LIST_DIR}/utilities.cmake" OPTIONAL)
+
+set(_IfcOpenShell_find_args)
+if(IfcOpenShell_FIND_VERSION)
+ list(APPEND _IfcOpenShell_find_args "${IfcOpenShell_FIND_VERSION}")
+ if(IfcOpenShell_FIND_VERSION_EXACT)
+ list(APPEND _IfcOpenShell_find_args EXACT)
+ endif()
+endif()
+list(APPEND _IfcOpenShell_find_args CONFIG QUIET)
+if(IfcOpenShell_FIND_COMPONENTS)
+ list(APPEND _IfcOpenShell_find_args COMPONENTS ${IfcOpenShell_FIND_COMPONENTS})
+endif()
+
+set(_IfcOpenShell_saved_module_path "${CMAKE_MODULE_PATH}")
+list(REMOVE_ITEM CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}")
+find_package(IfcOpenShell ${_IfcOpenShell_find_args})
+set(CMAKE_MODULE_PATH "${_IfcOpenShell_saved_module_path}")
+
+if(NOT IfcOpenShell_FOUND)
+ set(_IfcOpenShell_error "Could not find an IfcOpenShell CMake config package. Set IfcOpenShell_DIR or CMAKE_PREFIX_PATH.")
+ if(IfcOpenShell_FIND_REQUIRED)
+ message(FATAL_ERROR "${_IfcOpenShell_error}")
+ elseif(NOT IfcOpenShell_FIND_QUIETLY)
+ message(STATUS "${_IfcOpenShell_error}")
+ endif()
+ return()
+endif()
+
+set(_IfcOpenShell_required_targets IfcOpenShell::IfcParse IfcOpenShell::IfcGeom)
+set(_IfcOpenShell_missing_targets "")
+foreach(_IfcOpenShell_target IN LISTS _IfcOpenShell_required_targets)
+ if(NOT TARGET ${_IfcOpenShell_target})
+ list(APPEND _IfcOpenShell_missing_targets ${_IfcOpenShell_target})
+ endif()
+endforeach()
+
+if(_IfcOpenShell_missing_targets)
+ set(IfcOpenShell_FOUND FALSE)
+ string(REPLACE ";" ", " _IfcOpenShell_missing_targets_text "${_IfcOpenShell_missing_targets}")
+ set(_IfcOpenShell_error "IfcOpenShell config was found, but required targets are missing: ${_IfcOpenShell_missing_targets_text}.")
+ if(IfcOpenShell_FIND_REQUIRED)
+ message(FATAL_ERROR "${_IfcOpenShell_error}")
+ elseif(NOT IfcOpenShell_FIND_QUIETLY)
+ message(STATUS "${_IfcOpenShell_error}")
+ endif()
+ return()
+endif()
+
+if(NOT DEFINED IFCOPENSHELL_WITH_OPENCASCADE)
+ set(IFCOPENSHELL_WITH_OPENCASCADE OFF)
+ if(TARGET IfcOpenShell::geometry_kernel_opencascade)
+ set(IFCOPENSHELL_WITH_OPENCASCADE ON)
+ endif()
+endif()
+
+if(NOT DEFINED IFCOPENSHELL_WITH_CGAL)
+ set(IFCOPENSHELL_WITH_CGAL OFF)
+ if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
+ set(IFCOPENSHELL_WITH_CGAL ON)
+ endif()
+endif()
+
+if(NOT DEFINED IFCOPENSHELL_IFCXML)
+ set(IFCOPENSHELL_IFCXML OFF)
+endif()
+
+if(NOT DEFINED IFCOPENSHELL_WITH_ROCKSDB)
+ set(IFCOPENSHELL_WITH_ROCKSDB OFF)
+endif()
+
+set(IFCOPENSHELL_LIBRARIES IfcOpenShell::IfcParse)
+foreach(_IfcOpenShell_target IN ITEMS IfcOpenShell::geometry_serializer IfcOpenShell::Serializers)
+ if(TARGET ${_IfcOpenShell_target})
+ list(APPEND IFCOPENSHELL_LIBRARIES ${_IfcOpenShell_target})
+ endif()
+endforeach()
+
+set(IFCOPENSHELL_KERNEL_LIBRARIES "")
+foreach(_IfcOpenShell_target IN ITEMS
+ IfcOpenShell::geometry_kernel_opencascade
+ IfcOpenShell::geometry_kernel_cgal
+ IfcOpenShell::geometry_kernel_cgal_simple
+)
+ if(TARGET ${_IfcOpenShell_target})
+ list(APPEND IFCOPENSHELL_KERNEL_LIBRARIES ${_IfcOpenShell_target})
+ endif()
+endforeach()
+
+set(IFCOPENSHELL_GEOMETRY_LIBRARIES IfcOpenShell::IfcGeom ${IFCOPENSHELL_KERNEL_LIBRARIES})
+
+if(TARGET IfcOpenShell::OpenCASCADE_INTERFACE)
+ set(OpenCASCADE_LIBRARIES IfcOpenShell::OpenCASCADE_INTERFACE)
+endif()
+
+if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
+ set(CGAL_LIBRARIES IfcOpenShell::IFCOPENSHELL_CGAL)
+endif()
+
+if(TARGET IfcOpenShell::svgfill)
+ set(IFCOPENSHELL_SVGFILL_LIBRARY IfcOpenShell::svgfill)
+endif()
+
+mark_as_advanced(IfcOpenShell_DIR)
+
+unset(_IfcOpenShell_error)
+unset(_IfcOpenShell_find_args)
+unset(_IfcOpenShell_missing_targets)
+unset(_IfcOpenShell_missing_targets_text)
+unset(_IfcOpenShell_required_targets)
+unset(_IfcOpenShell_target)
diff --git a/cmake/IfcOpenShellConfig.cmake.in b/cmake/IfcOpenShellConfig.cmake.in
index e5e5d350b8..741e4fa233 100644
--- a/cmake/IfcOpenShellConfig.cmake.in
+++ b/cmake/IfcOpenShellConfig.cmake.in
@@ -7,12 +7,26 @@ set(IFCOPENSHELL_WITH_OPENCASCADE @WITH_OPENCASCADE@)
set(IFCOPENSHELL_WITH_CGAL @WITH_CGAL@)
set(IFCOPENSHELL_IFCXML @IFCXML_SUPPORT@)
set(IFCOPENSHELL_WITH_ROCKSDB @WITH_ROCKSDB@)
+set(IFCOPENSHELL_COLLADA_SUPPORT @COLLADA_SUPPORT@)
+set(IFCOPENSHELL_GLTF_SUPPORT @GLTF_SUPPORT@)
+set(IFCOPENSHELL_HDF5_SUPPORT @HDF5_SUPPORT@)
+set(IFCOPENSHELL_WITH_PROJ @WITH_PROJ@)
+set(IFCOPENSHELL_USD_SUPPORT @USD_SUPPORT@)
include(CMakeFindDependencyMacro)
-set(Boost_USE_STATIC_LIBS ON)
-set(Boost_USE_STATIC_RUNTIME OFF)
-set(Boost_USE_MULTITHREADED ON)
+set(IFCOPENSHELL_BOOST_USE_STATIC_LIBS "@Boost_USE_STATIC_LIBS@")
+set(IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME "@Boost_USE_STATIC_RUNTIME@")
+set(IFCOPENSHELL_BOOST_USE_MULTITHREADED "@Boost_USE_MULTITHREADED@")
+if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}" STREQUAL "")
+ set(Boost_USE_STATIC_LIBS ${IFCOPENSHELL_BOOST_USE_STATIC_LIBS})
+endif()
+if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}" STREQUAL "")
+ set(Boost_USE_STATIC_RUNTIME ${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME})
+endif()
+if(NOT "${IFCOPENSHELL_BOOST_USE_MULTITHREADED}" STREQUAL "")
+ set(Boost_USE_MULTITHREADED ${IFCOPENSHELL_BOOST_USE_MULTITHREADED})
+endif()
set(Boost_COMPONENTS
system
program_options
@@ -43,13 +57,33 @@ if(IFCOPENSHELL_WITH_ROCKSDB)
endif()
if(IFCOPENSHELL_IFCXML)
- find_dependency(LibXml2 CONFIG)
+ find_dependency(LibXml2)
endif()
if(IFCOPENSHELL_WITH_CGAL)
find_dependency(CGAL CONFIG)
endif()
+if(IFCOPENSHELL_COLLADA_SUPPORT)
+ find_dependency(OpenCOLLADA)
+endif()
+
+if(IFCOPENSHELL_GLTF_SUPPORT)
+ find_dependency(nlohmann_json CONFIG)
+endif()
+
+if(IFCOPENSHELL_HDF5_SUPPORT)
+ find_dependency(HDF5 COMPONENTS C CXX)
+endif()
+
+if(IFCOPENSHELL_WITH_PROJ)
+ find_dependency(PROJ)
+endif()
+
+if(IFCOPENSHELL_USD_SUPPORT)
+ find_dependency(USD)
+endif()
+
if(IFCOPENSHELL_WITH_OPENCASCADE)
find_dependency(OpenCASCADE CONFIG)
if(OpenCASCADE_VERSION VERSION_LESS "7.7.0")
diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile
index 6fc51c463a..c3324178df 100644
--- a/src/bonsai/Makefile
+++ b/src/bonsai/Makefile
@@ -106,7 +106,7 @@ endif
endif # def PLATFORM
# Current build commit hash.
-OLD:=1c5b825
+OLD:=3e7b739
.PHONY: bump
bump:
ifndef NEW
diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py
index fab7646162..4556a379d0 100644
--- a/src/bonsai/bonsai/bim/__init__.py
+++ b/src/bonsai/bonsai/bim/__init__.py
@@ -90,6 +90,7 @@ modules = {
"web": None,
"light": None,
"alignment": None,
+ "clip_box": None,
# Uncomment this line to enable loading of the demo module. Happy hacking!
# The name "demo" must correlate to a folder name in `bim/module/`.
# "demo": None,
diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py
index ca355cc8fa..81d577026a 100644
--- a/src/bonsai/bonsai/bim/import_ifc.py
+++ b/src/bonsai/bonsai/bim/import_ifc.py
@@ -223,6 +223,7 @@ class IfcImporter:
self.elements: set[ifcopenshell.entity_instance] = set()
self.annotations: set[ifcopenshell.entity_instance] = set()
self.gross_elements: set[ifcopenshell.entity_instance] = set()
+ self.broken_arrays: set[ifcopenshell.entity_instance] = set()
self.element_types: set[ifcopenshell.entity_instance] = set()
self.spatial_elements: set[ifcopenshell.entity_instance] = set()
self.meshes: dict[str, OBJECT_DATA_TYPE] = {}
@@ -1220,7 +1221,17 @@ class IfcImporter:
continue
for i in range(len(data)):
tool.Array.set_children_lock_state(element, i, True)
- tool.Array.constrain_children_to_parent(element)
+ tool.Array.constrain_children_to_parent(element)
+ for layer in data:
+ for child_guid in layer.get("children", ()):
+ try:
+ self.file.by_guid(child_guid)
+ except RuntimeError:
+ print(
+ f"setup_arrays: array parent {element.GlobalId} references missing "
+ f"child GUID {child_guid!r}."
+ )
+ self.broken_arrays.add(element)
def update_linked_aggregates(self):
# TODO Remove this after a while. See commit 17d6b8a
diff --git a/src/bonsai/bonsai/bim/module/cad/operator.py b/src/bonsai/bonsai/bim/module/cad/operator.py
index bef1d851af..5d74857822 100644
--- a/src/bonsai/bonsai/bim/module/cad/operator.py
+++ b/src/bonsai/bonsai/bim/module/cad/operator.py
@@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator):
class CadOffset(bpy.types.Operator):
bl_idname = "bim.cad_offset"
bl_label = "CAD Offset"
- bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
+ bl_description = (
+ "Offset selected mesh geometry at provided distance, based on the current viewport angle. "
+ "Creates a copy by default, or moves the existing edges if Copy is disabled."
+ )
bl_options = {"REGISTER", "UNDO"}
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
+ copy: bpy.props.BoolProperty(
+ name="Copy",
+ description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
+ default=True,
+ )
@classmethod
def poll(cls, context):
@@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator):
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
+ # When not copying, the offset positions are gathered here and applied to
+ # the existing verts only after all loops are processed, so that the
+ # original coordinates are still available while computing offsets.
+ moved_verts = []
+
# Create loops from edges
loop_edges = set(edges)
loops = []
@@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator):
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
new_vert = v1.co + offset
- new_verts.append(bm.verts.new(new_vert))
else:
normal = (normals[0] * self.distance).to_3d()
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
new_vert = v1.co + offset
+
+ if self.copy:
new_verts.append(bm.verts.new(new_vert))
+ else:
+ moved_verts.append((v1, new_vert))
processed_verts.add(v1.index)
@@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator):
v1 = v2
- [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
- if is_closed:
- bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
+ if self.copy:
+ [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
+ if is_closed:
+ bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
+
+ # Move the existing edges to the offset location.
+ for vert, new_co in moved_verts:
+ vert.co = new_co
bm.verts.index_update()
bm.edges.index_update()
diff --git a/src/bonsai/bonsai/bim/module/cad/prop.py b/src/bonsai/bonsai/bim/module/cad/prop.py
index 7dab36df91..aee4b9d55f 100644
--- a/src/bonsai/bonsai/bim/module/cad/prop.py
+++ b/src/bonsai/bonsai/bim/module/cad/prop.py
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
+ copy: bpy.props.BoolProperty(
+ name="Copy",
+ description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
+ default=True,
+ )
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
gable_roof_edge_angle: bpy.props.FloatProperty(
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
resolution: int
radius: float
distance: float
+ copy: bool
x: float
y: float
gable_roof_edge_angle: float
diff --git a/src/bonsai/bonsai/bim/module/cad/workspace.py b/src/bonsai/bonsai/bim/module/cad/workspace.py
index 24fb98ba3f..270c7f70b9 100644
--- a/src/bonsai/bonsai/bim/module/cad/workspace.py
+++ b/src/bonsai/bonsai/bim/module/cad/workspace.py
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
elif self.hotkey == "S_O":
row = self.layout.row()
row.prop(props, "distance")
+ row = self.layout.row()
+ row.prop(props, "copy")
elif self.hotkey == "S_R":
if tool.Geometry.is_profile_object_active():
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
def hotkey_S_O(self):
- bpy.ops.bim.cad_offset(distance=self.props.distance)
+ bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
def hotkey_S_Q(self):
obj = bpy.context.active_object
diff --git a/src/bonsai/bonsai/bim/module/clip_box/__init__.py b/src/bonsai/bonsai/bim/module/clip_box/__init__.py
new file mode 100644
index 0000000000..baebdd48f1
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/clip_box/__init__.py
@@ -0,0 +1,98 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+import bpy
+from bpy.app.handlers import persistent
+
+import bonsai.tool as tool
+
+from . import operator, prop, ui
+
+classes = (
+ operator.BIM_OT_add_clip_box,
+ operator.BIM_OT_duplicate_clip_box,
+ operator.BIM_OT_remove_clip_box,
+ operator.BIM_OT_set_active_clip_box,
+ operator.BIM_OT_toggle_clip_box_enabled,
+ prop.BIMClipBoxProperties,
+ prop.BIMSceneClipBoxProperties,
+ ui.BIM_UL_clip_box,
+ ui.BIM_PT_clip_box,
+)
+
+
+@persistent
+def _on_depsgraph_update(scene, depsgraph):
+ tool.ClipBox.on_depsgraph_update(scene, depsgraph)
+ tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph)
+
+
+@persistent
+def _on_load_post(filepath):
+ # Restore the per-scene clip-box list from the project's BBIM_ClipBoxes
+ # pset. Runs after the standard load_post that creates Blender objects.
+ tool.ClipBox._last_seen_object_matrices.clear()
+ tool.ClipBox.load_from_project_pset()
+
+
+_draw_handler_pre = None
+_draw_handler_post = None
+
+
+def register():
+ global _draw_handler_pre, _draw_handler_post
+ bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties)
+ bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties)
+ tool.ClipBox.reset_ownership()
+ if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post:
+ bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update)
+ if _on_load_post not in bpy.app.handlers.load_post:
+ bpy.app.handlers.load_post.append(_on_load_post)
+ if _draw_handler_pre is None:
+ _draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW")
+ if _draw_handler_post is None:
+ _draw_handler_post = bpy.types.SpaceView3D.draw_handler_add(
+ tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW"
+ )
+
+
+def unregister():
+ global _draw_handler_pre, _draw_handler_post
+ if _draw_handler_post is not None:
+ try:
+ bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW")
+ except ValueError:
+ pass
+ _draw_handler_post = None
+ if _draw_handler_pre is not None:
+ try:
+ bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW")
+ except ValueError:
+ pass
+ _draw_handler_pre = None
+ if _on_load_post in bpy.app.handlers.load_post:
+ bpy.app.handlers.load_post.remove(_on_load_post)
+ if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
+ bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
+ tool.ClipBox._cancel_pending_cap_rebuild()
+ tool.ClipBox._last_seen_object_matrices.clear()
+ tool.ClipBox.clear_clip_planes()
+ del bpy.types.Object.BIMClipBoxProperties
+ del bpy.types.Scene.BIMSceneClipBoxProperties
diff --git a/src/bonsai/bonsai/bim/module/clip_box/operator.py b/src/bonsai/bonsai/bim/module/clip_box/operator.py
new file mode 100644
index 0000000000..b080daa3de
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/clip_box/operator.py
@@ -0,0 +1,170 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+from __future__ import annotations
+
+import bpy
+
+import bonsai.tool as tool
+
+CLIP_BOX_NAME = "ClipBox"
+CLIP_BOX_COLLECTION = "BBIM_ClipBoxes"
+
+
+class BIM_OT_add_clip_box(bpy.types.Operator):
+ bl_idname = "bim.add_clip_box"
+ bl_label = "Add Clip Box"
+ bl_description = (
+ "Create a clip box empty at the 3D cursor. The empty's location, rotation, and scale "
+ "drive the viewport clip planes; resize with S, move with G, rotate with R"
+ )
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context):
+ scene_props = tool.ClipBox.get_scene_props(context.scene)
+
+ obj = bpy.data.objects.new(CLIP_BOX_NAME, None)
+ obj.empty_display_type = "CUBE"
+ obj.empty_display_size = 1.0
+ obj.location = context.scene.cursor.location.copy()
+ # Default to a 20m cube (scale 10 around [-1, +1] local cube) so
+ # the volume covers a typical building storey or two rather than
+ # the meaningless 2m unit cube. The user resizes with S.
+ obj.scale = (10.0, 10.0, 10.0)
+ obj.show_in_front = True
+
+ collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
+ collection.objects.link(obj)
+
+ obj_props = tool.ClipBox.get_object_props(obj)
+ obj_props.is_clip_box = True
+
+ entry = scene_props.clip_boxes.add()
+ entry.obj = obj
+ scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
+
+ # Adding a new clip box arms clipping so the user sees the cut
+ # immediately. Without this they'd have to find the panel
+ # toggle to discover the feature actually works.
+ scene_props.enabled = True
+
+ tool.Blender.set_active_object(obj)
+ tool.ClipBox.refresh(context.scene)
+ # Persist to the project pset so the box round-trips through IFC
+ # save/load. A project-level pset avoids the IfcRoot scale lock /
+ # strip that a per-entity placement would trigger on export.
+ tool.ClipBox.save_to_project_pset(context.scene)
+ return {"FINISHED"}
+
+
+class BIM_OT_remove_clip_box(bpy.types.Operator):
+ bl_idname = "bim.remove_clip_box"
+ bl_label = "Remove Clip Box"
+ bl_description = "Remove this clip box and its host empty"
+ bl_options = {"REGISTER", "UNDO"}
+
+ index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
+ delete_object: bpy.props.BoolProperty(default=True, name="Delete Host Object")
+
+ def execute(self, context):
+ scene_props = tool.ClipBox.get_scene_props(context.scene)
+ index = self.index if self.index >= 0 else scene_props.active_clip_box_index
+ if index < 0 or index >= len(scene_props.clip_boxes):
+ return {"CANCELLED"}
+
+ entry = scene_props.clip_boxes[index]
+ obj = entry.obj
+ scene_props.clip_boxes.remove(index)
+ if scene_props.active_clip_box_index >= len(scene_props.clip_boxes):
+ scene_props.active_clip_box_index = max(0, len(scene_props.clip_boxes) - 1)
+
+ if self.delete_object and obj is not None:
+ bpy.data.objects.remove(obj, do_unlink=True)
+
+ tool.ClipBox.refresh(context.scene)
+ tool.ClipBox.save_to_project_pset(context.scene)
+ return {"FINISHED"}
+
+
+class BIM_OT_set_active_clip_box(bpy.types.Operator):
+ bl_idname = "bim.set_active_clip_box"
+ bl_label = "Set Active Clip Box"
+ bl_description = "Set this clip box as the active one driving the viewport clip"
+ bl_options = {"REGISTER", "UNDO"}
+
+ index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
+
+ def execute(self, context):
+ scene_props = tool.ClipBox.get_scene_props(context.scene)
+ if self.index < 0 or self.index >= len(scene_props.clip_boxes):
+ return {"CANCELLED"}
+ scene_props.active_clip_box_index = self.index
+ return {"FINISHED"}
+
+
+class BIM_OT_toggle_clip_box_enabled(bpy.types.Operator):
+ bl_idname = "bim.toggle_clip_box_enabled"
+ bl_label = "Toggle Clip Box"
+ bl_description = "Toggle whether the active clip box is driving the viewport clip planes"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context):
+ scene_props = tool.ClipBox.get_scene_props(context.scene)
+ scene_props.enabled = not scene_props.enabled
+ return {"FINISHED"}
+
+
+class BIM_OT_duplicate_clip_box(bpy.types.Operator):
+ bl_idname = "bim.duplicate_clip_box"
+ bl_label = "Duplicate Clip Box"
+ bl_description = "Duplicate this clip box: copy its empty + matrix into a new entry"
+ bl_options = {"REGISTER", "UNDO"}
+
+ index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
+
+ def execute(self, context):
+ scene_props = tool.ClipBox.get_scene_props(context.scene)
+ source_index = self.index if self.index >= 0 else scene_props.active_clip_box_index
+ if source_index < 0 or source_index >= len(scene_props.clip_boxes):
+ return {"CANCELLED"}
+ source = scene_props.clip_boxes[source_index].obj
+ if source is None:
+ return {"CANCELLED"}
+
+ copy = bpy.data.objects.new(source.name, None)
+ copy.empty_display_type = source.empty_display_type
+ copy.empty_display_size = source.empty_display_size
+ copy.show_in_front = source.show_in_front
+ copy.matrix_world = source.matrix_world.copy()
+
+ collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
+ collection.objects.link(copy)
+
+ tool.ClipBox.get_object_props(copy).is_clip_box = True
+
+ entry = scene_props.clip_boxes.add()
+ entry.obj = copy
+ scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
+ scene_props.enabled = True
+
+ tool.Blender.set_active_object(copy)
+ tool.ClipBox.refresh(context.scene)
+ tool.ClipBox.save_to_project_pset(context.scene)
+ return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py
new file mode 100644
index 0000000000..f503d8781f
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py
@@ -0,0 +1,107 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import bpy
+from bpy.types import PropertyGroup
+
+import bonsai.tool as tool
+from bonsai.bim.prop import ObjProperty
+
+
+class BIMClipBoxProperties(PropertyGroup):
+ """Per-object marker for a clip-box host empty.
+
+ The host empty's ``matrix_world`` is the single source of truth for
+ the clip box's pose and dimensions: translation = box centre,
+ rotation = box orientation, per-axis scale = world half-extents. The
+ visible cube comes from the empty's CUBE display.
+
+ Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay
+ (``show_caps``) are global per-file and live on the Scene PG.
+ """
+
+ is_clip_box: bpy.props.BoolProperty(
+ default=False,
+ description="True when this empty was created as a clip-box host. Internal flag; not user-edited.",
+ )
+
+ if TYPE_CHECKING:
+ is_clip_box: bool
+
+
+def update_active_clip_box_index(self, context):
+ tool.ClipBox.schedule_refresh()
+ tool.ClipBox.select_active_clip_box(context)
+
+
+def update_show_caps(self, context):
+ tool.ClipBox.schedule_refresh()
+
+
+def update_enabled(self, context):
+ tool.ClipBox.schedule_refresh()
+
+
+class BIMSceneClipBoxProperties(PropertyGroup):
+ """Scene-level registry of clip boxes in this file.
+
+ Multiple boxes may exist; ``active_clip_box_index`` selects which one
+ drives the viewport clip at any time. ``enabled`` and ``show_caps``
+ are global because the user's intent ("hide everything outside the
+ box", "draw cap overlays") applies file-wide, not per box.
+
+ ``enabled`` is intentionally not persisted to the project pset:
+ opening a fresh IFC should never silently hide geometry behind a
+ remembered toggle. Selecting any clip-box empty in the viewport
+ re-arms it (see :meth:`tool.ClipBox._sync_active_to_selection`).
+ """
+
+ clip_boxes: bpy.props.CollectionProperty(type=ObjProperty)
+ active_clip_box_index: bpy.props.IntProperty(
+ default=0,
+ min=0,
+ update=update_active_clip_box_index,
+ description="Index of the clip box currently driving the viewport clip planes",
+ )
+ enabled: bpy.props.BoolProperty(
+ name="Enabled",
+ default=False,
+ update=update_enabled,
+ description="When enabled, the active clip box hides all viewport geometry outside its 6 faces",
+ )
+ show_caps: bpy.props.BoolProperty(
+ name="Show Caps",
+ default=True,
+ update=update_show_caps,
+ description=(
+ "Draw filled cross-section caps where IFC product geometry "
+ "crosses the active clip planes. Disable for performance on "
+ "very heavy scenes"
+ ),
+ )
+
+ if TYPE_CHECKING:
+ active_clip_box_index: int
+ enabled: bool
+ show_caps: bool
diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py
new file mode 100644
index 0000000000..b5ae95d9e2
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py
@@ -0,0 +1,86 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+from __future__ import annotations
+
+from bpy.types import Panel, UIList
+
+import bonsai.tool as tool
+
+
+class BIM_UL_clip_box(UIList):
+ def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
+ obj = item.obj
+ if obj is None:
+ layout.label(text="(missing)", icon="ERROR")
+ return
+ row = layout.row(align=True)
+ row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE")
+ row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index
+ row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
+
+
+class BIM_PT_clip_box(Panel):
+ bl_idname = "BIM_PT_clip_box"
+ bl_label = "Clip Box"
+ bl_space_type = "PROPERTIES"
+ bl_region_type = "WINDOW"
+ bl_context = "scene"
+ bl_options = {"DEFAULT_CLOSED"}
+ bl_parent_id = "BIM_PT_tab_sandbox"
+
+ def draw(self, context):
+ layout = self.layout
+ scene_props = tool.ClipBox.get_scene_props(context.scene)
+
+ toggles = layout.row(align=True)
+ toggles.scale_y = 2.0
+ toggles.prop(
+ scene_props,
+ "enabled",
+ text="Enable Clipping",
+ icon="HIDE_OFF" if scene_props.enabled else "HIDE_ON",
+ toggle=True,
+ )
+ toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True)
+
+ layout.separator()
+ layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
+
+ layout.template_list(
+ "BIM_UL_clip_box",
+ "",
+ scene_props,
+ "clip_boxes",
+ scene_props,
+ "active_clip_box_index",
+ rows=3,
+ )
+
+ obj = tool.ClipBox.get_active_clip_box(context.scene)
+ if obj is None:
+ layout.label(text="No active clip box", icon="INFO")
+ return
+
+ col = layout.column(align=True)
+ col.label(text="Edit the empty with G / R / S to move / rotate / resize")
+ col.prop(obj, "location")
+ col.prop(obj, "rotation_euler")
+ col.prop(obj, "scale")
diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
index 3c03e9db49..99db87d08d 100644
--- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py
+++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
@@ -159,40 +159,13 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
-_BONSAI_TRANSFORM_MACROS = frozenset(
- {
- # Bonsai overrides Blender's default move/duplicate keymaps with
- # macros that wrap TRANSFORM_OT_translate. While a macro is the outer
- # modal entry, the inner TRANSFORM_OT_translate does not surface in
- # window.modal_operators — the macro's own idname does. The
- # ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
- # runtime (the class declaration uses the dotted ``bim.`` form).
- "BIM_OT_override_move_macro", # G key
- "BIM_OT_override_object_duplicate_move_macro", # Shift+D
- "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
- "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
- }
-)
-
-
def _is_transform_modal_active(context) -> bool:
- """True iff a Blender transform modal (G/R/S and siblings, including
- Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
- updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of
- running modal operators. Parametric gizmo groups gate poll + draw_prepare
- on this so they hide for the duration of the drag instead of sliding
- off-cursor as the matrix updates each frame."""
- window = getattr(context, "window", None)
- if window is None:
- return False
- modal_ops = getattr(window, "modal_operators", None)
- if not modal_ops:
- return False
- for op in modal_ops:
- idname = op.bl_idname
- if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
- return True
- return False
+ """Module-local alias for ``tool.Blender.is_transform_modal_active``.
+
+ Preserved as a name so AST scans and call sites in this file stay
+ decoupled from the helper's home module.
+ """
+ return tool.Blender.is_transform_modal_active(context)
def _hide_all_non_modal_gizmos(group) -> None:
@@ -4877,7 +4850,14 @@ class GizmoDimension(GizmoMovable):
self.init_value = click_distance
- if self.initial_snap_state and self.active_obj:
+ # Schematic gizmos opt out of dimension snap. Force the header
+ # indicator to ``off`` for the drag's duration so the user sees the
+ # state matches behaviour; ``exit`` restores ``initial_snap_state``.
+ # Skipping the snap cache here also avoids the per-drag mesh probe.
+ snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
+ if not snap_supported:
+ context.scene.tool_settings.use_snap = False
+ elif self.initial_snap_state and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
@@ -4919,11 +4899,18 @@ class GizmoDimension(GizmoMovable):
if not region or not rv3d:
return {"RUNNING_MODAL"}
- tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
+ # Group-level opt-out: schematic gizmos float in viewport space, so
+ # global-snap-to-scene-vertices would produce spurious value jumps.
+ # The fallback (``True``) covers any gizmo whose group is not a
+ # ``BaseParametricGizmoGroup``.
+ snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
- if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
- build_snap_cache(context, self.active_obj)
- self._snap_cache_built = True
+ if snap_supported:
+ tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
+
+ if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
+ build_snap_cache(context, self.active_obj)
+ self._snap_cache_built = True
current_coord = (event.mouse_region_x, event.mouse_region_y)
@@ -4947,7 +4934,7 @@ class GizmoDimension(GizmoMovable):
delta = (current_3d - self.start_location).dot(axis_direction)
- if tool_settings.use_snap and self.active_obj:
+ if snap_supported and tool_settings.use_snap and self.active_obj:
# Snap the dimension tip (not mouse position) to target
# Calculate where the dimension tip would be with current delta
# The tip is at: gizmo_origin + axis * (init_value + delta)
@@ -5320,6 +5307,13 @@ class BaseParametricGizmoGroup:
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
+ # Default: dimension drags respect Blender's global snap (Ctrl-toggleable
+ # during drag). Subclasses whose dimensions float in viewport space rather
+ # than aligning to real-world geometry should override to ``False`` —
+ # snapping to scene vertices in that case produces spurious value jumps
+ # as the mouse crosses unrelated meshes.
+ snap_enabled_on_dimensions: bool = True
+
# === Icon Gizmo Layout (meters) ===
# Icons are positioned in a horizontal row above the element:
# [Validate] [Cancel] [Cycle]
@@ -6580,6 +6574,11 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
# list and become no-ops. The schematic equivalents below take their place.
dimension_gizmo_props: list[DimensionGizmoConfig] = []
+ # Schematic dimensions float in billboarded viewport space, not aligned to
+ # real-world geometry. Snapping the dragged tip to scene vertices would
+ # produce nonsensical value jumps as the mouse crosses unrelated meshes.
+ snap_enabled_on_dimensions: bool = False
+
# Declarative dimension configuration consumed by ``setup_schematic_dimensions``
# and ``update_schematic_dimensions``. Each config produces one
# ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local
diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py
index e34b5f7428..9bc0566532 100644
--- a/src/bonsai/bonsai/bim/module/geometry/operator.py
+++ b/src/bonsai/bonsai/bim/module/geometry/operator.py
@@ -890,6 +890,16 @@ class OverrideDelete(bpy.types.Operator):
# Track aggregates before deleting their parts
aggregates_to_check = self.track_aggregates(objects_to_remove)
+ # Snapshot the set of IFC entity ids being deleted in this batch so the
+ # connection-rel cascade inside `delete_ifc_object` can suppress
+ # partner-side regenerate when the partner is also about to vanish.
+ batch_being_deleted_ids: set[int] = set()
+ for obj in objects_to_remove:
+ if not tool.Blender.is_valid_data_block(obj):
+ continue
+ if (entity := tool.Ifc.get_entity(obj)) is not None:
+ batch_being_deleted_ids.add(entity.id())
+
clear_active_object = True
for i, obj in enumerate(objects_to_remove, 1):
@@ -931,7 +941,7 @@ class OverrideDelete(bpy.types.Operator):
if tool.Drawing.is_auto_annotation(element):
self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.")
continue
- tool.Geometry.delete_ifc_object(obj)
+ tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids)
elif tool.Geometry.is_representation_item(obj):
tool.Geometry.delete_ifc_item(obj)
else:
@@ -1030,7 +1040,10 @@ class OverrideDelete(bpy.types.Operator):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
- array_parents.add(ifc_file.by_guid(pset["Parent"]))
+ try:
+ array_parents.add(ifc_file.by_guid(pset["Parent"]))
+ except RuntimeError:
+ continue
for array_parent in array_parents:
array_parent_obj = tool.Ifc.get_object(array_parent)
diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py
index b30fb17896..6583144c89 100644
--- a/src/bonsai/bonsai/bim/module/model/__init__.py
+++ b/src/bonsai/bonsai/bim/module/model/__init__.py
@@ -110,6 +110,8 @@ classes = (
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
+ wall.GizmoSlabEdition,
+ wall.GizmoSlabUnjoinWalls,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
@@ -120,7 +122,7 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
- wall.UnjoinWallPathConnection,
+ wall.DisconnectElements,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
@@ -154,11 +156,14 @@ classes = (
slab.DisableEditingExtrusionProfile,
slab.DisableEditingSketchExtrusionProfile,
slab.AddSlabFromWall,
+ slab.CancelEditingSlab,
slab.DrawPolylineSlab,
slab.EditExtrusionProfile,
slab.EditSketchExtrusionProfile,
slab.EnableEditingExtrusionProfile,
slab.EnableEditingSketchExtrusionProfile,
+ slab.EnableEditingSlab,
+ slab.FinishEditingSlab,
slab.RecalculateSlab,
slab.ResetVertex,
slab.SetArcIndex,
@@ -185,6 +190,7 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
+ prop.BIMSlabProperties,
prop.BIMWallProperties,
prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties,
@@ -246,9 +252,13 @@ classes = (
railing.CopyRailingParameters,
railing.AddRailing,
railing.CancelEditingRailing,
+ railing.CycleRailingType,
railing.FinishEditingRailing,
+ railing.PickRailingTerminalType,
railing.FlipRailingPathOrder,
railing.EnableEditingRailing,
+ railing.GizmoRailingSchematic,
+ railing.ToggleRailingUseManualSupports,
railing.CancelEditingRailingPath,
railing.FinishEditingRailingPath,
railing.EnableEditingRailingPath,
diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py
index bef8f6fe11..956a08462c 100644
--- a/src/bonsai/bonsai/bim/module/model/array.py
+++ b/src/bonsai/bonsai/bim/module/model/array.py
@@ -423,7 +423,11 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
pset = tool.Ifc.get().by_id(pset["id"])
for array in arrays:
for child in set(array["children"]):
- if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
+ try:
+ child_element = tool.Ifc.get().by_guid(child)
+ except RuntimeError:
+ continue
+ if child_obj := tool.Ifc.get_object(child_element):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
# Always operate on the parent — this operator can be invoked with
diff --git a/src/bonsai/bonsai/bim/module/model/data.py b/src/bonsai/bonsai/bim/module/model/data.py
index 10553f1bed..36cde4eb0d 100644
--- a/src/bonsai/bonsai/bim/module/model/data.py
+++ b/src/bonsai/bonsai/bim/module/model/data.py
@@ -51,6 +51,10 @@ class AuthoringData:
@classmethod
def load(cls, ifc_element_type: Optional[str] = None):
+ # ``is_loaded`` is set first as a recursion guard: one of the data
+ # computations evaluates a PropertyGroup enum's ``items`` callback,
+ # which re-enters this method. Without the guard, load recurses to
+ # RecursionError.
cls.is_loaded = True
cls.props = tool.Model.get_model_props()
cls.data["default_container"] = cls.default_container()
diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py
index b9709c067a..e2f8a2a9a8 100644
--- a/src/bonsai/bonsai/bim/module/model/prop.py
+++ b/src/bonsai/bonsai/bim/module/model/prop.py
@@ -228,11 +228,7 @@ def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Co
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
- # Only FRAMELESS_PANEL can update live via bmesh.
- # WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation,
- # so it only updates on "Finish Editing" to avoid modifying IFC during preview.
- if self.railing_type == "FRAMELESS_PANEL":
- _get_updater("railing", "update_railing_modifier_bmesh")(context)
+ _get_updater("railing", "update_railing_modifier_bmesh")(context)
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
@@ -1693,6 +1689,21 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
+class BIMSlabProperties(PropertyGroup):
+ """Transient state for the slab disconnect-access gizmo.
+
+ ``is_editing`` flips True when the user clicks the pen icon on a slab
+ that has wall connections — gating the per-wall disconnect icons in
+ ``GizmoSlabUnjoinWalls`` so they're hidden until the user opts in. No
+ IFC draft state lives here: the disconnect operator commits directly,
+ so this PropertyGroup carries only the UI gate."""
+
+ is_editing: bpy.props.BoolProperty(name="Slab Edit Active", default=False, options={"SKIP_SAVE"})
+
+ if TYPE_CHECKING:
+ is_editing: bool
+
+
class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing.
diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py
index 6f3697d51d..825cc339ac 100644
--- a/src/bonsai/bonsai/bim/module/model/railing.py
+++ b/src/bonsai/bonsai/bim/module/model/railing.py
@@ -18,6 +18,7 @@
import json
+import math
from typing import Any
import bmesh
@@ -27,14 +28,24 @@ import ifcopenshell.api.geometry
import ifcopenshell.api.pset
import ifcopenshell.util.representation
import ifcopenshell.util.unit
-from mathutils import Vector
+from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
+from bonsai.bim.module.drawing import gizmos as gizmo
+from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
+from bonsai.bim.module.model import prop
from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
-from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
+from bonsai.bim.parametric_lifecycle import (
+ CycleTypeMixin,
+ PathPreservingEditMixin,
+ PickTypeMixin,
+)
+from bonsai.tool.cad import WELD_TOLERANCE
+
+V_ = tool.Blender.V_
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -125,6 +136,56 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": railing_data})
+def generate_wall_mounted_handrail_preview(
+ obj: bpy.types.Object,
+ props: "BIMRailingProperties",
+ path_data: dict[str, Any],
+ si_conversion: float,
+) -> None:
+ """Viewport-only WALL_MOUNTED_HANDRAIL preview: rebuild ``obj.data`` from the same
+ geometry helper the IFC representation builder uses, without writing any IFC."""
+ railing_path = [Vector(v) * si_conversion for v in path_data["verts"]]
+ looped_path = path_data["edges"][-1][-1] == path_data["edges"][0][0]
+
+ geom = ifcopenshell.api.geometry.compute_wall_mounted_handrail_geometry(
+ railing_path=railing_path,
+ support_spacing=props.support_spacing,
+ railing_diameter=props.railing_diameter,
+ clear_width=props.clear_width,
+ height=props.height,
+ use_manual_supports=props.use_manual_supports,
+ terminal_type=props.terminal_type,
+ looped_path=looped_path,
+ unit_scale=1.0, # props are already SI; bypass the IFC project-units conversion
+ )
+
+ bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
+
+ tool.Cad.sweep_disk_along_polyline(
+ bm,
+ [Vector(p) for p in geom.handrail_polyline],
+ geom.handrail_radius,
+ arc_indices=geom.handrail_arc_point_indices,
+ )
+
+ for support in geom.supports:
+ tool.Cad.sweep_disk_along_polyline(
+ bm,
+ [Vector(p) for p in support.arc_polyline],
+ support.arc_radius,
+ )
+ tool.Cad.add_disk_extrusion(
+ bm,
+ Vector(support.disk_position),
+ support.disk_radius,
+ support.disk_depth,
+ support.disk_z_rotation,
+ )
+
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
+ tool.Blender.apply_bmesh(obj.data, bm)
+
+
def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
"""before using should make sure that Data contains up-to-date information.
If BBIM Pset just changed should call refresh() before updating bmesh
@@ -140,6 +201,13 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
path_data = RailingData.data["path_data"]
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
+
+ # WALL_MOUNTED_HANDRAIL renders the preview from the compute helper; IFC stays
+ # untouched until Finish Editing rebuilds the representation.
+ if not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL":
+ generate_wall_mounted_handrail_preview(obj, props, path_data, si_conversion)
+ return
+
# need to make sure we support edit mode
# since users will probably be in edit mode when they'll be changing railing path
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
@@ -165,8 +233,6 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
thickness = props.thickness
spacing = props.spacing
- # spacing
- # split each edge in 3 segments by 0.5 * spacing by x-y plane
main_edges = bm.edges[:]
for main_edge in main_edges:
bm_split_edge_at_offset(main_edge, spacing)
@@ -211,7 +277,7 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve)
bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve)
# to remove unnecessary verts in 0 spacing case
- bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
+ bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
@@ -271,8 +337,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]:
segments.append((i - 1, 0))
break
- # skip path verts if they just go vertical to avoid errors
- if (v.co.xy - prev_v.co.xy).length <= 0.0001:
+ # Vertical-only segments project to a degenerate XY edge; skip to avoid divide-by-zero downstream.
+ if (v.co.xy - prev_v.co.xy).length <= WELD_TOLERANCE:
continue
points.append(v.co)
@@ -407,9 +473,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
class _RailingEditMixin(PathPreservingEditMixin):
- """Type-specific hooks for railing parametric-edit operators. Single-object
- (active_object). ``path_data`` is preserved through the edit; the separate
- ``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
+ """Single-object (active_object) railing-edit hooks; path_data is preserved
+ through the edit (path editing is a separate operator family)."""
pset_name = "BBIM_Railing"
@@ -436,7 +501,21 @@ class _RailingEditMixin(PathPreservingEditMixin):
update_railing_modifier_ifc_data(context)
@classmethod
- def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ """WALL_MOUNTED_HANDRAIL reloads the committed Body; others rebuild the preview bmesh."""
+ props = tool.Model.get_railing_props(obj)
+ if props.railing_type == "WALL_MOUNTED_HANDRAIL":
+ element = tool.Ifc.get_entity(obj)
+ assert element
+ body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
+ if body:
+ bonsai.core.geometry.switch_representation(
+ tool.Ifc,
+ tool.Geometry,
+ obj=obj,
+ representation=body,
+ )
+ return
update_railing_modifier_bmesh(context)
@@ -467,6 +546,554 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera
return self._finish_targets(context)
+class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
+ """Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses."""
+
+ bl_idname = "bim.cycle_railing_type"
+ bl_label = "Cycle Railing Type"
+ bl_options = {"REGISTER", "UNDO"}
+
+ element_checker = tool.Parametric.is_railing
+ props_getter = tool.Model.get_railing_props
+ type_literal = tool.Model.RailingType
+ type_attr = "railing_type"
+
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ return self._cycle_type(context)
+
+
+class ToggleRailingUseManualSupports(bpy.types.Operator):
+ """Flip use_manual_supports on the active WALL_MOUNTED_HANDRAIL railing.
+
+ No-op unless a parametric edit is active and the railing is wall-mounted.
+ """
+
+ bl_idname = "bim.toggle_railing_use_manual_supports"
+ bl_label = "Toggle Railing Manual Supports"
+ bl_description = "Switch between automatic support spacing and manual per-vertex placement"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context):
+ resolved = tool.Model.resolve_active_props_for_edit(
+ context,
+ tool.Model.get_railing_props,
+ subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
+ )
+ if resolved is None:
+ return {"CANCELLED"}
+ _obj, props = resolved
+ props.use_manual_supports = not props.use_manual_supports
+ return {"FINISHED"}
+
+
+class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
+ """Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
+
+ bl_idname = "bim.pick_railing_terminal_type"
+ bl_label = "Pick Railing Terminal Type"
+ bl_description = "Pick the cap geometry applied at the rail ends"
+ bl_options = {"REGISTER", "UNDO"}
+
+ skip_element_check = True
+ props_getter = tool.Model.get_railing_props
+ type_literal = prop.CapType
+ type_attr = "terminal_type"
+
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ if (
+ tool.Model.resolve_active_props_for_edit(
+ context,
+ tool.Model.get_railing_props,
+ subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
+ )
+ is None
+ ):
+ return {"CANCELLED"}
+ return self._pick_type(context)
+
+
+def _format_attr_distance(attr_name: str):
+ """text_formatter that renders the named property as a distance, ignoring the
+ dimension's visible-length argument (which is fixed for schematic gizmos)."""
+ return lambda p, _v: tool.Unit.format_distance(getattr(p, attr_name))
+
+
+class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup):
+ """Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode."""
+
+ bl_idname = "OBJECT_GGT_bim_railing_edition"
+ bl_label = "Railing Editing Gizmo"
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "WINDOW"
+ bl_options = {"3D", "PERSISTENT"}
+
+ enable_editing_operator = "bim.enable_editing_railing"
+ finish_editing_operator = "bim.finish_editing_railing"
+ cancel_editing_operator = "bim.cancel_editing_railing"
+ cycle_type_operator = "bim.cycle_railing_type"
+
+ props_getter = tool.Model.get_railing_props
+ gizmo_pref_name = "railing"
+
+ # Schematic-local layout. +X → screen RIGHT, +Y → screen UP, +Z → toward viewer
+ # (post billboard rotation). Each dimension is anchored alongside the feature it
+ # measures so the label, not the bar length, carries the value.
+ SCHEMATIC_MESH_HEIGHT_FRAC = 0.9 # Mesh top edge in schematic-local +Y
+ SCHEMATIC_MESH_WIDTH_FRAC = 0.7 # Mesh side edges in schematic-local ±X
+ SCHEMATIC_MESH_RAIL_Y_FRAC = SCHEMATIC_MESH_HEIGHT_FRAC / 2 # WALL_MOUNTED_HANDRAIL rail centreline
+ SCHEMATIC_MESH_DEPTH_FRAC = 0.06 # Panel depth — small so the schematic reads as slabs not boxes
+ # WALL_MOUNTED_HANDRAIL dimensions — fractions of schematic_box_size so they
+ # scale with the host group's box size.
+ SCHEMATIC_RAIL_RADIUS_FRAC = 0.05
+ SCHEMATIC_RAIL_CLEAR_FRAC = 0.5 # Stylised — wider than real-world for visible bracket arm
+ SCHEMATIC_RAIL_INSET_FRAC = 0.08 # Wall extends past the outermost support on both sides
+
+ @classmethod
+ def schematic_rail_radius(cls) -> float:
+ return cls.schematic_box_size * cls.SCHEMATIC_RAIL_RADIUS_FRAC
+
+ @classmethod
+ def schematic_rail_clear(cls) -> float:
+ return cls.schematic_box_size * cls.SCHEMATIC_RAIL_CLEAR_FRAC
+
+ # Axonometric 3/4 view: +Z projects down-and-left so the depth axis
+ # is visibly separated from the back face. Without the X tilt, panel
+ # thickness (schematic-local Z) collapses to a near-horizontal bar.
+ schematic_view_rotation = Matrix.Rotation(math.radians(20), 4, "X") @ Matrix.Rotation(math.radians(-25), 4, "Y")
+
+ # Hover a dimension → highlight the schematic edges tagged with the matching feature.
+ # Tags are written by the mesh builders. "spacing" is empty space (no edges) so it's
+ # absent from this map and gracefully no-ops on hover.
+ schematic_attr_to_feature = {
+ "height": "panel_height",
+ "thickness": "panel_thickness",
+ "railing_diameter": "rail_tube",
+ "clear_width": "bracket",
+ "support_spacing": "bracket",
+ }
+
+ schematic_dimension_props = [
+ # ── FRAMELESS_PANEL ─────────────────────────────────────────────
+ DimensionGizmoConfig(
+ attr_name="height",
+ axis=(0, 1, 0),
+ min_value=0.01,
+ # Gated to FRAMELESS_PANEL: in WALL_MOUNTED_HANDRAIL, height only
+ # feeds TO_FLOOR / TO_END_POST_AND_FLOOR terminals so dragging it
+ # is a no-op under the default "180" terminal.
+ visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
+ matrix_position=lambda p: Vector((-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.08, 0.0, 0.0)),
+ schematic_visible_length=SCHEMATIC_MESH_HEIGHT_FRAC,
+ text_formatter=_format_attr_distance("height"),
+ ),
+ DimensionGizmoConfig(
+ attr_name="thickness",
+ axis=(0, 0, 1), # panel depth — projects to a true depth direction under the 3/4 tilt
+ min_value=0.005,
+ visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
+ matrix_position=lambda p: Vector(
+ (
+ (
+ -GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
+ - GizmoRailingSchematic.SCHEMATIC_MESH_GAP_HALF_WIDTH
+ )
+ / 2,
+ GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC + 0.05,
+ -GizmoRailingSchematic.SCHEMATIC_MESH_DEPTH_FRAC / 2,
+ )
+ ),
+ schematic_visible_length=0.4, # longer than default to survive depth foreshortening
+ text_formatter=_format_attr_distance("thickness"),
+ ),
+ DimensionGizmoConfig(
+ attr_name="spacing",
+ axis=(1, 0, 0),
+ min_value=0.0, # zero-spacing collapses the picket gap into a single continuous panel
+ visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
+ matrix_position=lambda p: Vector((0.0, -0.1, 0.0)),
+ text_formatter=_format_attr_distance("spacing"),
+ ),
+ # ── WALL_MOUNTED_HANDRAIL ──────────────────────────────────────
+ DimensionGizmoConfig(
+ attr_name="railing_diameter",
+ axis=(0, 1, 0),
+ min_value=0.001,
+ visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
+ matrix_position=lambda p: Vector(
+ (
+ -GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.05,
+ GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC - 0.09,
+ GizmoRailingSchematic.schematic_rail_clear(),
+ )
+ ),
+ text_formatter=_format_attr_distance("railing_diameter"),
+ ),
+ DimensionGizmoConfig(
+ attr_name="clear_width",
+ axis=(0, 0, 1), # +Z is the wall-to-rail perpendicular axis under the 3/4 tilt
+ min_value=0.001,
+ visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
+ matrix_position=lambda p: Vector(
+ (
+ 0.0,
+ GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC,
+ 0.0,
+ )
+ ),
+ schematic_visible_length=0.36, # 2× default so the call-out survives depth projection
+ text_formatter=_format_attr_distance("clear_width"),
+ ),
+ DimensionGizmoConfig(
+ attr_name="support_spacing",
+ axis=(1, 0, 0),
+ min_value=0.05,
+ visibility_condition=lambda p: (p.railing_type == "WALL_MOUNTED_HANDRAIL" and not p.use_manual_supports),
+ matrix_position=lambda p: Vector(
+ (
+ -GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
+ + GizmoRailingSchematic.SCHEMATIC_RAIL_INSET_FRAC,
+ -0.18,
+ 0.0,
+ )
+ ),
+ # Bare names (not Gizmo…SCHEMATIC_…) because the class is still under construction here.
+ schematic_visible_length=SCHEMATIC_MESH_WIDTH_FRAC - 2 * SCHEMATIC_RAIL_INSET_FRAC,
+ text_formatter=_format_attr_distance("support_spacing"),
+ ),
+ ]
+
+ @classmethod
+ def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
+ return tool.Parametric.is_railing(element)
+
+ @classmethod
+ def schematic_cache_key(cls, props) -> tuple:
+ """Cache the schematic mesh by ``railing_type`` — proportions are fixed
+ per type, so the bmesh build runs at most twice across a session
+ (once for ``FRAMELESS_PANEL``, once for ``WALL_MOUNTED_HANDRAIL``)
+ rather than once per draw call."""
+ return (props.railing_type,)
+
+ def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
+ """Create the WALL_MOUNTED_HANDRAIL-only affordances on the schematic.
+
+ Two static lock glyphs (open/closed) for toggling
+ ``use_manual_supports``: instantiate both and let the per-frame state
+ query pick which one to show. State-aware icons use a static pair
+ rather than a single dynamic gizmo to avoid ``prop_path`` resolution
+ in the render path.
+
+ Plus a cycle-glyph at the rail end that opens the ``terminal_type``
+ popup when clicked.
+ """
+ default_color, highlight_color = self.get_decoration_colors()
+
+ self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
+ "bim.toggle_railing_use_manual_supports",
+ open_color=default_color,
+ )
+
+ self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu")
+ self.terminal_gizmo.color = default_color
+ self.terminal_gizmo.color_highlight = highlight_color
+ self.terminal_gizmo.use_draw_scale = False
+ self.terminal_gizmo.alpha = 0.8
+ self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type")
+
+ def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
+ """Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos.
+
+ - Lock glyphs: only WALL_MOUNTED_HANDRAIL while editing. Show
+ ``lock_open`` when ``use_manual_supports`` is True, the closed
+ padlock when False ("auto-spacing is locked to support_spacing").
+ - Terminal gizmo: same gating, positioned just past the right rail
+ end so it reads as "configure the rail's end cap".
+ """
+ super()._refresh_element_specific(context, mw, props)
+
+ # ``draw_prepare`` can fire on a freshly recreated GizmoGroup instance
+ # before ``setup_element_specific_gizmos`` has populated the lock /
+ # terminal attributes (Blender 5.x recreates per-region groups on
+ # reload). Bail out cheaply; the next refresh after setup completes
+ # will reposition them correctly.
+ if not hasattr(self, "lock_open_gizmo"):
+ return
+
+ # Single gate for all WALL_MOUNTED_HANDRAIL extras.
+ active = props.is_editing and not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL"
+
+ if not active:
+ self.lock_open_gizmo.hide = True
+ self.lock_closed_gizmo.hide = True
+ self.terminal_gizmo.hide = True
+ return
+
+ billboard_rot = self._frame_billboard_rot
+ view_rotation = self.schematic_view_rotation
+ anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
+
+ # ── Lock glyphs for use_manual_supports ──────────────────────────
+ # Sit just above the wall's bottom line, near the centre of the
+ # schematic — visually grouped with the dimension it controls
+ # (support_spacing) without overlapping the arrow tail below.
+ is_manual = bool(props.use_manual_supports)
+ self.lock_open_gizmo.hide = not is_manual
+ self.lock_closed_gizmo.hide = is_manual
+ lock_local = Vector((0.0, 0.05, 0.0))
+ lock_world = anchor + billboard_rot @ view_rotation @ lock_local
+ lock_matrix = gizmo.billboarded_at(lock_world, billboard_rot, 0.09)
+ self.lock_open_gizmo.matrix_basis = lock_matrix
+ self.lock_closed_gizmo.matrix_basis = lock_matrix
+
+ # ── Terminal-type popup gizmo at the right rail end ──────────────
+ # Pushed well past the right wall edge so the icon doesn't crowd
+ # the wall outline or the bracket attach point. At rail height and
+ # rail depth so it reads as "attached to the rail terminal".
+ self.terminal_gizmo.hide = False
+ terminal_local = Vector(
+ (
+ self.SCHEMATIC_MESH_WIDTH_FRAC / 2 + 0.25,
+ self.SCHEMATIC_MESH_RAIL_Y_FRAC,
+ self.schematic_rail_clear(),
+ )
+ )
+ terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
+ self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
+
+ def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
+ """Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
+
+ The base class shows the pen gizmo whenever ``is_editing`` is False,
+ which is the case during path-edit too. Allowing the user to click
+ through into parametric edit while the polyline mesh is open in EDIT
+ mode mixes two distinct editing states and leaves a stale draft if
+ they cancel out — block the entry point instead. The operator itself
+ is intentionally not guarded (callers via scripting can still invoke
+ it); this is the UX-level enforcement.
+
+ The cycle icon defaults to the editing icon row (next to validate /
+ cancel) via the parent's positioning. We move it to just above the
+ schematic mesh so it reads as "cycle the railing type *shown here*"
+ — associated with the preview the user is interacting with, not a
+ generic editing button at the bottom of the schematic.
+ """
+ super().update_editing_gizmos(context, mw, props)
+ if props.is_editing_path:
+ self.pen_gizmo.hide = True
+
+ if props.is_editing and not props.is_editing_path:
+ billboard_rot = self._frame_billboard_rot
+ view_rotation = self.schematic_view_rotation
+ anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
+ # Comfortably above the mesh top edge so the icon doesn't crowd
+ # the ``thickness`` / ``clear_width`` dimension callouts that
+ # already sit just above the panel/wall.
+ cycle_local = Vector((0.0, self.SCHEMATIC_MESH_HEIGHT_FRAC + 0.25, 0.0))
+ world_pos = anchor + billboard_rot @ view_rotation @ cycle_local
+ # 30% smaller than the editing-icon-row default (0.30 → 0.21):
+ # the cycle is a tertiary affordance compared to pen/validate/cancel.
+ self.cycle_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, 0.21)
+
+ @classmethod
+ def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh":
+ """Build a wireframe preview of the railing in schematic-local coordinates.
+
+ FRAMELESS_PANEL renders as a box whose proportions track the bound
+ properties (height / thickness / spacing); WALL_MOUNTED_HANDRAIL
+ renders as a horizontal tube with two L-shaped supports whose
+ proportions track railing_diameter / clear_width / support_spacing.
+ Both are scaled to fit inside ``[-schematic_box_size, +schematic_box_size]``
+ on each axis so the schematic reads the same regardless of absolute
+ property values.
+
+ The mesh is decorative — clicks land on the labeled sliders, not on
+ the preview geometry. See ``BaseSchematicGizmoGroup`` for the
+ draw-handler lifecycle.
+ """
+ bm = bmesh.new()
+ if props.railing_type == "FRAMELESS_PANEL":
+ cls._build_frameless_panel_schematic(bm, props)
+ else:
+ cls._build_wall_mounted_handrail_schematic(bm, props)
+ return bm
+
+ # Schematic-local half-width of the visible gap between the two panel boxes.
+ # Conveys the "spacing" semantic at a glance — the user sees two pickets
+ # separated by air, with the spacing dimension emerging from that gap.
+ SCHEMATIC_MESH_GAP_HALF_WIDTH = 0.05
+
+ @classmethod
+ def _build_frameless_panel_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
+ """Stylised panel: two wireframe boxes with a visible gap between them.
+
+ The box edges sit at the ``SCHEMATIC_MESH_*_FRAC`` positions
+ (matching where the dimension gizmos anchor), so each dimension line
+ visually starts at the geometry feature it measures. Internal
+ proportions are stable across drags — the actual values are shown
+ through the dimension labels, while the schematic communicates
+ which feature each label refers to. The gap between the two boxes
+ (set by ``SCHEMATIC_MESH_GAP_HALF_WIDTH``) gives the "spacing"
+ dimension a real visual referent.
+
+ Edges are tagged on a string layer so hover-highlight can colour
+ the geometric feature being measured: vertical edges → height,
+ depth edges → thickness. The X-aligned edges along the panel
+ width are untagged (they don't correspond to a single dimension).
+ """
+ hw = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
+ hd = cls.SCHEMATIC_MESH_DEPTH_FRAC / 2
+ h_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
+ gap = cls.SCHEMATIC_MESH_GAP_HALF_WIDTH
+
+ layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
+ feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
+
+ # Edge index → feature tag for one box. Order matches the (a, b)
+ # tuple order below: bottom ring (4) + top ring (4) + verticals (4).
+ edge_tags_per_box = (
+ b"", # (0,1) bottom-back, X-aligned
+ b"panel_thickness", # (1,2) bottom-right, Z-aligned
+ b"", # (2,3) bottom-front, X-aligned
+ b"panel_thickness", # (3,0) bottom-left, Z-aligned
+ b"", # (4,5) top-back, X-aligned
+ b"panel_thickness", # (5,6) top-right, Z-aligned
+ b"", # (6,7) top-front, X-aligned
+ b"panel_thickness", # (7,4) top-left, Z-aligned
+ b"panel_height", # (0,4) vertical back-left
+ b"panel_height", # (1,5) vertical back-right
+ b"panel_height", # (2,6) vertical front-right
+ b"panel_height", # (3,7) vertical front-left
+ )
+
+ # Build two separate wireframe boxes — one on each side of the central
+ # gap. The boxes share the same Y range (0..h_top) and Z range (±hd)
+ # but split the X range so the gap from -gap to +gap stays empty.
+ for x_left, x_right in ((-hw, -gap), (gap, hw)):
+ corners = [
+ bm.verts.new((x_left, 0.0, -hd)),
+ bm.verts.new((x_right, 0.0, -hd)),
+ bm.verts.new((x_right, 0.0, hd)),
+ bm.verts.new((x_left, 0.0, hd)),
+ bm.verts.new((x_left, h_top, -hd)),
+ bm.verts.new((x_right, h_top, -hd)),
+ bm.verts.new((x_right, h_top, hd)),
+ bm.verts.new((x_left, h_top, hd)),
+ ]
+ for tag, (a, b) in zip(
+ edge_tags_per_box,
+ (
+ (0, 1),
+ (1, 2),
+ (2, 3),
+ (3, 0), # bottom ring
+ (4, 5),
+ (5, 6),
+ (6, 7),
+ (7, 4), # top ring
+ (0, 4),
+ (1, 5),
+ (2, 6),
+ (3, 7), # vertical edges
+ ),
+ ):
+ edge = bm.edges.new((corners[a], corners[b]))
+ if tag:
+ edge[feat_layer] = tag
+
+ @classmethod
+ def _build_wall_mounted_handrail_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
+ """Stylised wall-mounted handrail: wall outline, hex tube, two L-brackets.
+
+ Three visual elements convey "rail mounted on a wall":
+
+ - **Wall outline** — a wireframe rectangle in the YZ plane at ``z=0``,
+ extending slightly past the rail ends so the wall reads as a
+ surface the rail is *attached to* rather than a coincident frame.
+ - **Handrail tube** — a hexagonal cross-section extruded along ±X
+ at ``z=+clear_s`` (in front of the wall), at ``y=rail_y``.
+ - **L-shaped brackets** at each rail end — from the rail centreline
+ drop a short distance, then run perpendicular back to the wall
+ plane. Mirrors the standard wall-mount bracket geometry: a
+ horizontal arm holding the rail off the wall, a vertical drop
+ attaching to the rail.
+
+ Like ``_build_frameless_panel_schematic``, the schematic uses fixed
+ proportions so the dimension gizmos' anchor points stay aligned
+ with the geometry features regardless of property values.
+ """
+ half_len = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
+ wall_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
+ rail_y = cls.SCHEMATIC_MESH_RAIL_Y_FRAC # rail sits at half wall height
+ radius_s = cls.schematic_rail_radius()
+ clear_s = cls.schematic_rail_clear()
+
+ layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
+ feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
+
+ # ── Wall outline (rectangle at z=0, slightly wider than the rail) ──
+ # Spans the full schematic height; the rail attaches in the middle,
+ # so the wall reads as "continuing past the rail above and below".
+ # Wall edges stay untagged — they're background context, not a
+ # feature any dimension measures.
+ wall_extra = 0.08
+ wall_x_left = -half_len - wall_extra
+ wall_x_right = half_len + wall_extra
+ wall_corners = [
+ bm.verts.new((wall_x_left, 0.0, 0.0)),
+ bm.verts.new((wall_x_right, 0.0, 0.0)),
+ bm.verts.new((wall_x_right, wall_top, 0.0)),
+ bm.verts.new((wall_x_left, wall_top, 0.0)),
+ ]
+ for a, b in ((0, 1), (1, 2), (2, 3), (3, 0)):
+ bm.edges.new((wall_corners[a], wall_corners[b]))
+
+ # ── Handrail tube (hex cross-section in YZ, extruded along X) ──────
+ # Centred on the rail centreline at (±(half_len - rail_inset),
+ # rail_y, +clear_s) — in front of the wall plane at z=0. The tube
+ # is shorter than the wall so the wall visibly extends past it on
+ # both sides; the L-brackets sit at the tube ends, so the leftmost
+ # bracket no longer coincides with the wall's left edge.
+ rail_inset = cls.SCHEMATIC_RAIL_INSET_FRAC
+ rail_x_left = -half_len + rail_inset
+ rail_x_right = half_len - rail_inset
+ segments = 6
+ ring_left, ring_right = [], []
+ for i in range(segments):
+ theta = 2 * math.pi * i / segments
+ dy = math.cos(theta) * radius_s
+ dz = math.sin(theta) * radius_s
+ ring_left.append(bm.verts.new((rail_x_left, rail_y + dy, clear_s + dz)))
+ ring_right.append(bm.verts.new((rail_x_right, rail_y + dy, clear_s + dz)))
+ # All hex-tube edges tagged "rail_tube" so they highlight together
+ # when the railing_diameter dimension is hovered.
+ for i in range(segments):
+ j = (i + 1) % segments
+ e_left = bm.edges.new((ring_left[i], ring_left[j]))
+ e_right = bm.edges.new((ring_right[i], ring_right[j]))
+ e_axial = bm.edges.new((ring_left[i], ring_right[i]))
+ e_left[feat_layer] = b"rail_tube"
+ e_right[feat_layer] = b"rail_tube"
+ e_axial[feat_layer] = b"rail_tube"
+
+ # ── L-brackets at each rail end (rail → drop → wall) ───────────────
+ # Bracket attach points follow the rail ends, so they're pulled
+ # inward by ``rail_inset`` from the wall edges. From the rail
+ # centreline, drop ``bracket_drop`` in Y, then run perpendicular
+ # back to the wall plane (z=0). The L shape reads as a wall-mount
+ # bracket under the 3/4 tilt. Both bracket segments tagged
+ # "bracket" so they highlight when clear_width OR support_spacing
+ # is hovered (both dimensions measure features of the supports).
+ bracket_drop = 0.06
+ for x in (rail_x_left, rail_x_right):
+ v_rail = bm.verts.new((x, rail_y, clear_s))
+ v_corner = bm.verts.new((x, rail_y - bracket_drop, clear_s))
+ v_wall = bm.verts.new((x, rail_y - bracket_drop, 0.0))
+ e1 = bm.edges.new((v_rail, v_corner))
+ e2 = bm.edges.new((v_corner, v_wall))
+ e1[feat_layer] = b"bracket"
+ e2[feat_layer] = b"bracket"
+
+
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.flip_railing_path_order"
bl_label = "Flip Railing Path Order"
@@ -510,6 +1137,16 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator):
[o.select_set(False) for o in context.selected_objects if o != obj]
assert obj
props = tool.Model.get_railing_props(obj)
+
+ # Auto-commit any in-progress parametric draft before switching to
+ # path-edit. ``set_props_kwargs_from_ifc_data`` a few lines below
+ # overwrites props with the pset's stored values — without committing
+ # first, anything the user dragged on a dimension gizmo (height,
+ # diameter, …) would be silently discarded the moment path-edit
+ # starts.
+ if props.is_editing:
+ tool.Parametric.commit_object_draft(obj, "bim.finish_editing_railing")
+
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
# required since we could load pset from .ifc and BIMRoofProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py
index 58a353ab28..9d7e88a23d 100644
--- a/src/bonsai/bonsai/bim/module/model/slab.py
+++ b/src/bonsai/bonsai/bim/module/model/slab.py
@@ -271,7 +271,7 @@ class DumbSlabPlaner:
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
- existing_x_angle = obj.rotation_euler.x
+ existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
@@ -991,3 +991,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.recalculate_walls(walls)
return {"FINISHED"}
+
+
+class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
+ """Open the slab disconnect-access mode. Pure UI toggle: flips
+ ``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect
+ gizmos surface on the slab. ``tool.Ifc.Operator`` base because the
+ parametric framework's universal dispatcher routes through
+ ``tool.Parametric.run_bim_op``, which only accepts that subclass for
+ undo-safe lifecycle. No IFC mutation."""
+
+ bl_idname = "bim.enable_editing_slab"
+ bl_label = "Edit Slab Connections"
+ bl_description = "Show disconnect icons for every wall clipped to this slab"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ obj = context.active_object
+ if obj is None:
+ return False
+ element = tool.Ifc.get_entity(obj)
+ return element is not None and element.is_a("IfcSlab")
+
+ def _execute(self, context):
+ context.active_object.BIMSlabProperties.is_editing = True
+ return {"FINISHED"}
+
+
+class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
+ """Close the slab disconnect-access mode."""
+
+ bl_idname = "bim.cancel_editing_slab"
+ bl_label = "Close Slab Edit"
+ bl_description = "Hide the slab disconnect icons"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ obj = context.active_object
+ if obj is None:
+ return False
+ element = tool.Ifc.get_entity(obj)
+ return element is not None and element.is_a("IfcSlab")
+
+ def _execute(self, context):
+ context.active_object.BIMSlabProperties.is_editing = False
+ return {"FINISHED"}
+
+
+class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
+ """Close the slab disconnect-access mode. Same body as Cancel — slab
+ edit is a pure UI gate with no IFC draft to commit; the framework
+ requires both ``bim.finish_editing_`` and
+ ``bim.cancel_editing_`` to exist by name convention."""
+
+ bl_idname = "bim.finish_editing_slab"
+ bl_label = "Finish Slab Edit"
+ bl_description = "Hide the slab disconnect icons"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ obj = context.active_object
+ if obj is None:
+ return False
+ element = tool.Ifc.get_entity(obj)
+ return element is not None and element.is_a("IfcSlab")
+
+ def _execute(self, context):
+ context.active_object.BIMSlabProperties.is_editing = False
+ return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py
index dd9d21697c..ecbe544eb9 100644
--- a/src/bonsai/bonsai/bim/module/model/wall.py
+++ b/src/bonsai/bonsai/bim/module/model/wall.py
@@ -48,6 +48,7 @@ import mathutils.geometry
import numpy as np
from mathutils import Matrix, Vector
+import bonsai.core.connection
import bonsai.core.geometry
import bonsai.core.model as core
import bonsai.core.root
@@ -108,6 +109,50 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
return True
+def _resolve_active_partner_pair(
+ context: bpy.types.Context,
+) -> "tuple[bpy.types.Object, bpy.types.Object, ifcopenshell.entity_instance, ifcopenshell.entity_instance] | None":
+ """Return ``(active_obj, partner_obj, active_elem, partner_elem)`` for a
+ selection of exactly two IFC-bound objects with the active one named,
+ else ``None``. Used by every 2-selection gizmo to skip the standard
+ "resolve active + partner + IFC entities" preamble."""
+ active = tool.Blender.get_active_object(is_selected=True)
+ if active is None:
+ return None
+ selected = list(tool.Blender.get_selected_objects())
+ if len(selected) != 2:
+ return None
+ partner = next((o for o in selected if o != active), None)
+ if partner is None:
+ return None
+ active_elem = tool.Ifc.get_entity(active)
+ partner_elem = tool.Ifc.get_entity(partner)
+ if active_elem is None or partner_elem is None:
+ return None
+ return active, partner, active_elem, partner_elem
+
+
+def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_editing: bool = False) -> bool:
+ """Shared gate for slab-side connection gizmos: exactly 1 IfcSlab
+ selected, not an array child, has at least one wall clipped to its
+ underside. With ``require_editing=True`` additionally requires the
+ slab's parametric edit lifecycle to be active (pen icon clicked) so
+ the gizmo only surfaces after explicit opt-in."""
+ active = tool.Blender.get_active_object(is_selected=True)
+ if active is None:
+ return False
+ if len(tool.Blender.get_selected_objects()) != 1:
+ return False
+ element = tool.Ifc.get_entity(active)
+ if element is None or not element.is_a("IfcSlab"):
+ return False
+ if tool.Blender.Modifier.any_selected_is_array_child():
+ return False
+ if require_editing and not tool.Model.get_slab_props(active).is_editing:
+ return False
+ return any(tool.Wall.iter_slab_wall_connections(element))
+
+
def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter.
@@ -246,6 +291,15 @@ def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> N
_maybe_resync_wall_props_from_ifc(obj)
+def _regenerate_walls(objs: "Iterable[bpy.types.Object | None]") -> None:
+ """Rebuild every wall in ``objs`` from current IFC state — extrusion,
+ openings, and any underside slab clip — so the caller doesn't carry
+ feature-specific dispatch."""
+ for obj in objs:
+ if obj is not None:
+ tool.Model.regenerate_wall(obj)
+
+
class _CommitWallDraftsFirstMixin:
"""Operator mixin that flushes any in-progress wall parametric drafts in
the current selection before delegating to the subclass's ``_perform``.
@@ -285,19 +339,29 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
-class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
- """Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one
- specific partner wall, leaving the active wall's other connections intact. The
- partner is identified by IFC GlobalId — invariant under Blender-object renames,
- file save/reload, and the undo stack — set on the operator properties by the
- single-wall unjoin gizmo at click time."""
+class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
+ """Disconnect two IFC elements given their GlobalIds — generic dispatcher
+ that infers the connection rel kind via tool.Connection.find_rels and runs
+ the right post-disconnect cleanup:
- bl_idname = "bim.unjoin_wall_path_connection"
- bl_label = "Unjoin Wall Connection"
- bl_description = "Disconnect the active wall from a single specific partner wall"
+ - ``"path"`` (IfcRelConnectsPathElements) → removes every rel between
+ the pair (catches both orientations) via remove_connection + recreates
+ both walls + resyncs drafts.
+ - ``"element-top"`` (IfcRelConnectsElements with Description=="TOP") →
+ disconnect_element + regenerate_wall_to_underside on the wall side.
+ - ``"element"`` (other IfcRelConnectsElements) → disconnect_element only.
+
+ Both endpoints by GlobalId so the dispatch survives rename / undo / save.
+ Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab
+ operators with one entry-point gizmos and shortcuts can bind to."""
+
+ bl_idname = "bim.disconnect_elements"
+ bl_label = "Disconnect Elements"
+ bl_description = "Remove the connection between two IFC elements identified by GlobalId"
bl_options = {"REGISTER", "UNDO"}
- other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId")
+ element_a_guid: bpy.props.StringProperty(name="Element A GlobalId")
+ element_b_guid: bpy.props.StringProperty(name="Element B GlobalId")
@classmethod
def poll(cls, context):
@@ -309,44 +373,54 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
return True
def _perform(self, context):
- active = tool.Blender.get_active_object(is_selected=True)
- if not active:
- self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
+ ifc_file = tool.Ifc.get()
+ try:
+ elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None
+ elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None
+ except RuntimeError:
+ elem_a = elem_b = None
+ if elem_a is None or elem_b is None:
+ self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.")
return
- elem_active = tool.Ifc.get_entity(active)
- if not elem_active:
- self.report({"ERROR"}, "Active object is not bound to an IFC entity.")
+ rels = tool.Connection.find_rels(elem_a, elem_b)
+ if not rels:
+ self.report({"ERROR"}, "No connection found between elements.")
return
- elem_other = None
- if self.other_wall_guid:
- try:
- elem_other = tool.Ifc.get().by_guid(self.other_wall_guid)
- except RuntimeError:
- elem_other = None
- other = tool.Ifc.get_object(elem_other) if elem_other else None
- if not elem_other or not other:
- self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
+ # The fillet corner's join with its source walls defines the fillet's
+ # identity — unjoining there would tear down the chord axis reference
+ # without rebuilding the source walls' miter cuts. Deleting the corner
+ # wall is the supported teardown, which cascades back to the source
+ # walls via the connection-cleanup handler.
+ either_is_fillet = tool.Parametric.is_fillet_corner_wall(elem_a) or tool.Parametric.is_fillet_corner_wall(
+ elem_b
+ )
+ if either_is_fillet and any(k == "path" for _, k in rels):
+ self.report(
+ {"INFO"},
+ "Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.",
+ )
return
- # Walk the inverse graph for the specific IfcRelConnectsPathElements joining
- # these two walls and remove only that one. `disconnect_path`'s
- # (relating, related) mode only inspects `relating.ConnectedTo`, so a single
- # call misses the rel when it was authored with the opposite orientation.
- rels = [
- rel
- for rel in getattr(elem_active, "ConnectedTo", [])
- if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other
- ] + [
- rel
- for rel in getattr(elem_active, "ConnectedFrom", [])
- if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other
- ]
- for rel in rels:
- bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
- # Recreate body+axis on both walls so the mesh state matches the IFC mutation
- # and stale miter cuts are dropped.
- tool.Model.recreate_wall(elem_active, active)
- tool.Model.recreate_wall(elem_other, other)
- _resync_walls_after_mutation([active, other])
+ path_objs: list[bpy.types.Object] = []
+ for rel, kind in rels:
+ bonsai.core.connection.disconnect_rel(
+ tool.Ifc,
+ tool.Geometry,
+ tool.Model,
+ tool.Connection,
+ rel=rel,
+ kind=kind,
+ elem=elem_a,
+ partner=elem_b,
+ )
+ if kind == "path":
+ obj_a = tool.Ifc.get_object(elem_a)
+ obj_b = tool.Ifc.get_object(elem_b)
+ if obj_a is not None and obj_a not in path_objs:
+ path_objs.append(obj_a)
+ if obj_b is not None and obj_b not in path_objs:
+ path_objs.append(obj_b)
+ if path_objs:
+ _resync_walls_after_mutation(path_objs)
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
@@ -369,7 +443,7 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to
element = tool.Ifc.get_entity(obj)
if not element:
continue
- if tool.Model.get_usage_type(element) == "LAYER2":
+ if tool.Parametric.is_path_connectable_wall(element):
walls.append(obj)
else:
slabs.append(obj)
@@ -390,7 +464,7 @@ class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator):
wall_objs = [
obj
for obj in tool.Blender.get_selected_objects()
- if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2"
+ if (element := tool.Ifc.get_entity(obj)) and tool.Parametric.is_path_connectable_wall(element)
]
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
@@ -642,9 +716,14 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
def _perform(self, context):
selected_objs = tool.Model.get_selected_mesh_objects()
+ post_split_walls: list[bpy.types.Object] = []
for obj in selected_objs:
- DumbWallJoiner().split(obj, context.scene.cursor.location)
- _resync_walls_after_mutation(selected_objs)
+ new_obj = DumbWallJoiner().split(obj, context.scene.cursor.location)
+ post_split_walls.append(obj)
+ if new_obj is not None and new_obj not in post_split_walls:
+ post_split_walls.append(new_obj)
+ _resync_walls_after_mutation(post_split_walls)
+ _regenerate_walls(post_split_walls)
return {"FINISHED"}
@@ -674,11 +753,13 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
active_obj = context.active_object
assert active_obj
selected_objs = tool.Model.get_selected_mesh_objects()
- # The merge deletes the second argument when the walls are collinear;
- # only the first survives, so the resync targets the non-active wall.
- surviving_obj = next(o for o in selected_objs if o != active_obj)
- DumbWallJoiner().merge(surviving_obj, active_obj)
- _maybe_resync_wall_props_from_ifc(surviving_obj)
+ # Active-is-survivor — matches Blender's Ctrl+J / "merge at last"
+ # convention. The first argument survives, the second is consumed,
+ # so the active wall ends up absorbing the other.
+ other_obj = next(o for o in selected_objs if o != active_obj)
+ DumbWallJoiner().merge(active_obj, other_obj)
+ _maybe_resync_wall_props_from_ifc(active_obj)
+ _regenerate_walls([active_obj])
return {"FINISHED"}
@@ -745,6 +826,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
+ _resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
@@ -791,7 +873,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
extrusion.Depth = perpendicular_depth
else:
if tool.Model.get_usage_type(element) == "LAYER3":
- existing_x_angle = obj.rotation_euler.x
+ existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
@@ -860,6 +942,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
+ _resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
@@ -882,6 +965,7 @@ class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator):
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
for obj in selected_objs:
joiner.set_length(obj, self.length)
+ _resync_walls_after_mutation(selected_objs)
class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator):
@@ -1463,7 +1547,7 @@ class DumbWallJoiner:
body = copy.deepcopy(axis1["reference"])
tool.Model.recreate_wall(element1, wall1)
- def split(self, wall1: bpy.types.Object, target: Vector) -> None:
+ def split(self, wall1: bpy.types.Object, target: Vector) -> "bpy.types.Object | None":
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
element1 = tool.Ifc.get_entity(wall1)
@@ -1484,6 +1568,13 @@ class DumbWallJoiner:
wall2 = self.duplicate_wall(wall1)
element2 = tool.Ifc.get_entity(wall2)
+ # The duplicate inherits wall1's slab-trim boolean chain (copied by
+ # copy_class) but ``BBIM_Boolean.Data`` carries wall1's stale ids, so
+ # ``get_manual_booleans(element2)`` returns empty and the regenerator
+ # rebuilds wall2's body without those clips. Strip them up front so
+ # wall2 starts clean before the axis + placement reshape.
+ tool.Model.strip_underside_booleans(element2)
+
# Get the ATEND connection from wall1 to use it in wall2
relating_element = None
connections = element1.ConnectedTo
@@ -1543,13 +1634,16 @@ class DumbWallJoiner:
r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings
]:
rel = opening.HasFillings[0]
- filling = rel.RelatedBuildingElement
- filling_obj = tool.Ifc.get_object(filling)
- filling_location = filling_obj.matrix_world.translation
- _, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d)
min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
+ # Use the opening's axis-projected midpoint to classify the side.
+ # The filling's ``matrix_world.translation`` is flip-fragile —
+ # flipping rotates the filler 180° + translates so the bbox
+ # stays visually in place, moving the door origin to the
+ # opposite corner, which would mis-classify a flipped door
+ # centred over the cut.
+ opening_midpoint = (min_t + max_t) / 2
void_straddles = min_t < cut_percentage < max_t
- if filling_position > cut_percentage:
+ if opening_midpoint > cut_percentage:
# The filling should be moved from element1 to element2.
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
new_opening.VoidsElements[0].RelatingBuildingElement = element2
@@ -1564,13 +1658,16 @@ class DumbWallJoiner:
rel.RelatingOpeningElement = new_opening
- # Remove the old opening
- ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
-
if void_straddles:
# Filling moved to element2, but void straddles — add a
- # pure-void copy back to element1 so its body still gets cut.
- _add_void_copy(element1, new_opening)
+ # pure-void copy back to element1. Read from the original
+ # ``opening`` whose ObjectPlacement still references
+ # element1; ``new_opening`` was rebound to element2 and
+ # would copy element2's frame instead.
+ _add_void_copy(element1, opening)
+
+ # Remove the old opening
+ ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
elif void_straddles:
# Filling stays on element1, but void straddles — add a pure-void
# copy to element2 so its body gets cut.
@@ -1583,6 +1680,7 @@ class DumbWallJoiner:
tool.Model.recreate_wall(element1, wall1)
tool.Model.recreate_wall(element2, wall2)
+ return wall2
def flip(self, wall1: bpy.types.Object) -> None:
if tool.Ifc.is_moved(wall1):
@@ -1638,7 +1736,14 @@ class DumbWallJoiner:
p2[0] = max(x_ordinates)
self.set_axis(element1, p1, p2)
+ # ConnectedTo / ConnectedFrom carry both ``IfcRelConnectsPathElements``
+ # (the wall-wall joins this loop migrates) and
+ # ``IfcRelConnectsElements`` (the slab underside clip). Only the
+ # path rels expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
+ # the element rels die with element2 via the trailing cascade delete.
for rel in element2.ConnectedTo:
+ if not rel.is_a("IfcRelConnectsPathElements"):
+ continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType
)
@@ -1651,6 +1756,8 @@ class DumbWallJoiner:
)
for rel in element2.ConnectedFrom:
+ if not rel.is_a("IfcRelConnectsPathElements"):
+ continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType
)
@@ -1662,6 +1769,26 @@ class DumbWallJoiner:
related_connection=rel.RelatedConnectionType,
)
+ # Re-host openings from the discarded wall to the survivor before
+ # the cascade delete tears down element2's voids and any filling
+ # that depends on them. ``edit_object_placement`` preserves the
+ # opening's world position when element1 and element2 have
+ # different placements — a ``PlacementRelTo`` swap alone would
+ # shift the opening as the relative offset changes.
+ ifc_file = tool.Ifc.get()
+ for rel in list(element2.HasOpenings):
+ opening = rel.RelatedOpeningElement
+ rel.RelatingBuildingElement = element1
+ if opening.ObjectPlacement:
+ world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement)
+ ifcopenshell.api.geometry.edit_object_placement(
+ ifc_file,
+ product=opening,
+ matrix=world_matrix,
+ is_si=False,
+ should_transform_children=False,
+ )
+
tool.Model.recreate_wall(element1, wall1)
tool.Geometry.delete_ifc_object(wall2)
@@ -2458,7 +2585,9 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator):
tool.Model,
context.scene.cursor.location,
)
- _resync_walls_after_mutation(tool.Blender.get_selected_objects())
+ affected = list(tool.Blender.get_selected_objects())
+ _resync_walls_after_mutation(affected)
+ _regenerate_walls(affected)
return {"FINISHED"}
@@ -2491,6 +2620,7 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
bpy.ops.bim.change_extrusion_depth(depth=new_height)
_maybe_resync_wall_props_from_ifc(obj)
+ _regenerate_walls([obj])
return {"FINISHED"}
@@ -3117,6 +3247,12 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp
# the banana body. If a neighbour moved, the new placement follows; if
# neither moved, the new matrix equals the old within floating-point noise.
_apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj)
+ # The body rebuild swaps the wall's representation, so any prior underside
+ # clip is gone. Re-clip from the surviving TOP rels so an extend-to-slab
+ # applied to a fillet wall isn't silently wiped on the next neighbour
+ # recalc, ChangeExtrusionDepth, or split / merge call site.
+ if tool.Model.has_underside_connection(element):
+ core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [obj])
class EnableWallFilletPreview(bpy.types.Operator):
@@ -3236,6 +3372,19 @@ class CancelWallFilletPreview(bpy.types.Operator):
props = preview_base.get_preview_props(context, "wall_fillet")
if props is None or not props.is_active:
return {"CANCELLED"}
+ # Clear the corner's edit flag so the connection disconnect gizmos
+ # disappear in lockstep with the radius preview when the user
+ # cancels. The id read happens BEFORE clear_preview_state wipes it.
+ corner_id = props.editing_corner_id
+ if corner_id:
+ ifc_file = tool.Ifc.get()
+ if ifc_file is not None:
+ try:
+ corner_obj = tool.Ifc.get_object(ifc_file.by_id(corner_id))
+ except RuntimeError:
+ corner_obj = None
+ if corner_obj is not None:
+ tool.Model.get_wall_props(corner_obj).is_editing = False
preview_base.clear_preview_state(props)
return {"FINISHED"}
@@ -3309,6 +3458,11 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
props.radius = float(radius)
props.editing_corner_id = corner_elem.id()
props.is_active = True
+ # Flag the corner as "in edit mode" so the wall-side connection
+ # disconnect gizmos surface in parallel with the fillet preview —
+ # one pen-icon click enters BOTH radius retune AND connection
+ # inspection.
+ tool.Model.get_wall_props(corner_obj).is_editing = True
return {"FINISHED"}
@@ -3589,7 +3743,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
return False
other = next(o for o in selected if o is not active)
other_element = tool.Ifc.get_entity(other)
- if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2":
+ if not other_element or not tool.Parametric.is_path_connectable_wall(other_element):
return False
return True
@@ -3855,15 +4009,17 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at
- every join location inferred from the wall's IfcRelConnectsPathElements inverse
- graph — the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall
- unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND
- by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated
- and hidden on a per-frame basis based on the live connection set.
+ every connection location on the wall — wall-wall path connections via
+ IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements
+ with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART
+ + 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped
+ slab), so a pool of icons is preallocated and hidden on a per-frame basis based
+ on the live connection set.
- Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner
- wall's GlobalId set on the bound operator properties, so a click removes only
- the single rel under that icon — the other connections on the same wall survive.
+ Each visible icon dispatches `bim.disconnect_elements` with the active wall +
+ partner element GlobalIds set on the bound operator properties, so a click
+ removes only the single rel under that icon — the other connections on the
+ same wall survive.
Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group
requires len(selected) == 2; this one requires 1)."""
@@ -3881,10 +4037,25 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
# creation is forbidden — so the pool must be sized upfront for the worst case.
POOL_SIZE = 16
ICON_SCALE = 0.35
+ SLAB_STACK_MAX = 5
+ SLAB_STACK_OFFSET_Z = 0.5
+ # Muted gray used for connection icons that are visible (the connection
+ # exists) but inert (clicking dispatches a no-op + INFO report). Fillet
+ # corner ↔ source-wall joins use this — disconnecting them would tear
+ # down the fillet's chord axis reference, so the supported teardown is
+ # deleting the corner wall instead.
+ LOCKED_COLOR: ClassVar[tuple[float, float, float]] = (0.5, 0.5, 0.5)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
- if not _wall_topology_gizmo_poll_gate(context):
+ # Bypass the shared topology gate's ``any_preview_active`` block —
+ # ``BIMWallProperties.is_editing`` is the real gate for this gizmo
+ # group, and that flag is set both by the regular wall edit lifecycle
+ # AND by the fillet preview entry (so a fillet corner under preview
+ # surfaces its connections in parallel with the radius drag).
+ if not tool.Blender.are_viewport_gizmos_enabled():
+ return False
+ if tool.Blender.Modifier.any_selected_is_array_child():
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -3902,6 +4073,9 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
+ # Stashed so per-frame ``_bind_unjoin_icon`` can restore the active
+ # tone when an icon was muted in a previous frame for fillet lock.
+ self._default_unjoin_color = default_color
# Bind the operator on each pool icon ONCE at setup time and keep the returned
# OperatorProperties handles. target_set_operator allocates a fresh handle on
# every call, so calling it from position_gizmos (which fires every redraw
@@ -3911,11 +4085,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
self.unjoin_op_props = []
for _ in range(self.POOL_SIZE):
icon = self.setup_icon_gizmo(
- "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection"
+ "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
icon.hide = True
self.unjoin_icons.append(icon)
- self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection"))
+ self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
def position_gizmos(self, context: bpy.types.Context) -> None:
# Default: hide every pool slot. The visible-set is rebuilt from the live
@@ -3936,15 +4110,28 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
- connections = _get_wall_connections_cached(self, elem)
- if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
+ path_connections = _get_wall_connections_cached(self, elem)
+ slab_connections = list(tool.Wall.iter_wall_slab_connections(elem))
+ slab_overflow = max(0, len(slab_connections) - self.SLAB_STACK_MAX)
+ if slab_overflow and not getattr(self, "_slab_cap_warned", False):
print(
- f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; "
+ f"[bonsai] GizmoWallUnjoinSingle: wall has {len(slab_connections)} slab "
+ f"connections; only the first {self.SLAB_STACK_MAX} are shown stacked."
+ )
+ self._slab_cap_warned = True
+ slab_connections = slab_connections[: self.SLAB_STACK_MAX]
+ total = len(path_connections) + len(slab_connections)
+ if total > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
+ print(
+ f"[bonsai] GizmoWallUnjoinSingle: wall has {total} connections "
+ f"({len(path_connections)} path + {len(slab_connections)} slab); "
f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
)
self._pool_cap_warned = True
- for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections):
+ slot_idx = 0
+ self_is_fillet = tool.Parametric.is_fillet_corner_wall(elem)
+ for other_elem, self_ct, other_ct in path_connections:
if slot_idx >= self.POOL_SIZE:
break
other_obj = tool.Ifc.get_object(other_elem)
@@ -3955,20 +4142,224 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
continue
seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom)
location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct)
+ is_locked = self_is_fillet or tool.Parametric.is_fillet_corner_wall(other_elem)
+ self._bind_unjoin_icon(
+ slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj, is_locked=is_locked
+ )
+ slot_idx += 1
+
+ for stack_idx, (slab_elem, _rel) in enumerate(slab_connections):
+ if slot_idx >= self.POOL_SIZE:
+ break
+ slab_obj = tool.Ifc.get_object(slab_elem)
+ if slab_obj is None:
+ continue
+ location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
+ if location is None:
+ continue
+ # Stack vertically so each slab gets a distinct clickable icon;
+ # hover-highlight then shows the user which slab they're about to
+ # disconnect from.
+ stacked = location + Vector((0.0, 0.0, stack_idx * self.SLAB_STACK_OFFSET_Z))
+ self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj)
+ slot_idx += 1
+
+ def _bind_unjoin_icon(
+ self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj, *, is_locked=False
+ ):
+ """Place + bind one pool icon to a (active, partner) GlobalId pair.
+
+ Only the GlobalId properties are rewritten per frame; the operator
+ binding itself is the long-lived handle set up at setup() time. GlobalId
+ (not Blender object name) keeps the binding stable across renames, file
+ save/reload, and any sit-in-the-undo-stack interlude between dispatch
+ and execute. The partner Blender object is mirrored onto the icon for
+ its hover-outline draw, since the Gizmo API exposes
+ ``target_set_operator`` but no symmetric reader.
+
+ ``is_locked=True`` (fillet corner involvement) writes a muted color
+ instead of the active tone; the GUIDs still propagate so the bound
+ operator can surface a friendly INFO report on click."""
+ icon = self.unjoin_icons[slot_idx]
+ icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE)
+ icon.hide = False
+ icon.color = self.LOCKED_COLOR if is_locked else self._default_unjoin_color
+ self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId
+ self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId
+ icon.partner_obj = partner_obj
+
+
+class GizmoSlabUnjoinWalls(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
+ """Slab-side mirror of GizmoWallUnjoinSingle: when exactly one IfcSlab is
+ selected and at least one wall is clipped to its underside, surface an
+ unjoin icon at each connection point. The icons resolve at the same
+ world location as the wall-side gizmo (via the symmetric
+ tool.Wall.wall_slab_connection_location_world) so the same connection
+ has a single visual marker reachable from either selection.
+
+ Each visible icon dispatches bim.disconnect_elements with the slab +
+ wall GlobalIds, so a click removes the single rel under that icon and
+ re-clips the wall to whatever remaining slabs it's connected to."""
+
+ bl_idname = "OBJECT_GGT_bim_slab_unjoin_walls"
+ bl_label = "Slab Unjoin Walls Gizmo"
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "WINDOW"
+ bl_options = {"3D", "PERSISTENT"}
+
+ POOL_SIZE = 16
+ ICON_SCALE = 0.35
+
+ @classmethod
+ def poll(cls, context: bpy.types.Context) -> bool:
+ return _slab_connection_gizmo_poll_gate(context, require_editing=True)
+
+ def setup(self, context: bpy.types.Context) -> None:
+ default_color, highlight_color = self.get_decoration_colors()
+ self.unjoin_icons = []
+ self.unjoin_op_props = []
+ for _ in range(self.POOL_SIZE):
+ icon = self.setup_icon_gizmo(
+ "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
+ )
+ icon.hide = True
+ self.unjoin_icons.append(icon)
+ self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
+
+ def position_gizmos(self, context: bpy.types.Context) -> None:
+ for icon in self.unjoin_icons:
+ icon.hide = True
+
+ selected = list(tool.Blender.get_selected_objects())
+ if len(selected) != 1:
+ return
+ slab_obj = selected[0]
+ slab_elem = tool.Ifc.get_entity(slab_obj)
+ if slab_elem is None:
+ return
+
+ billboard_rot = gizmo.get_billboard_rotation(context)
+ clearance = gizmo.top_down_clearance(context, billboard_rot)
+ connections = list(tool.Wall.iter_slab_wall_connections(slab_elem))
+ if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
+ print(
+ f"[bonsai] GizmoSlabUnjoinWalls: slab has {len(connections)} wall connections; "
+ f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
+ )
+ self._pool_cap_warned = True
+
+ slot_idx = 0
+ for wall_elem, _rel in connections:
+ if slot_idx >= self.POOL_SIZE:
+ break
+ wall_obj = tool.Ifc.get_object(wall_elem)
+ if wall_obj is None:
+ continue
+ location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
+ if location is None:
+ continue
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
- # Only the partner-GlobalId property is rewritten per frame; the operator
- # binding itself is the long-lived handle set up at setup() time. GlobalId
- # (not Blender object name) keeps the binding stable across renames, file
- # save/reload, and any sit-in-the-undo-stack interlude between dispatch
- # and execute.
- self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId
- # Mirror the partner reference onto the icon itself so its draw()
- # can outline the partner on hover without a Gizmo-side getter on
- # the bound operator (the API exposes target_set_operator with
- # no symmetric reader).
- icon.partner_obj = other_obj
+ self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId
+ self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId
+ icon.partner_obj = wall_obj
+ slot_idx += 1
+
+
+class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
+ """Pen / validate / cancel triad for slab disconnect-access mode.
+
+ Polls on a single IfcSlab with at least one wall clipped to its underside.
+ Pen routes through the universal ``bim.enable_editing_parametric``
+ dispatcher; finish + cancel both clear ``is_editing`` (no IFC mutation —
+ the framework requires the triad to exist by name convention even for a
+ pure UI gate). ESC, the red-coloured cancel icon, mutual exclusion with
+ other active parametric edits, gizmo prefs gating — all handled by the
+ base class."""
+
+ bl_idname = "OBJECT_GGT_bim_slab_edition"
+ bl_label = "Slab Editing Gizmo"
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "WINDOW"
+ bl_options = {"3D", "PERSISTENT"}
+
+ enable_editing_operator = "bim.enable_editing_slab"
+ finish_editing_operator = "bim.finish_editing_slab"
+ cancel_editing_operator = "bim.cancel_editing_slab"
+ cycle_type_operator = ""
+
+ props_getter = tool.Model.get_slab_props
+ gizmo_pref_name = "slab"
+
+ @classmethod
+ def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
+ return tool.Parametric.is_slab(element) and any(tool.Wall.iter_slab_wall_connections(element))
+
+
+class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
+ """Surfaces a disconnect icon when exactly 2 IFC elements are selected
+ and they share a supported rel — currently the wall + slab pair joined
+ by an ``IfcRelConnectsElements(TOP)``. Click dispatches
+ ``bim.disconnect_elements`` with both GlobalIds. For wall-wall pairs,
+ ``GizmoWallJoinIntersection``'s unjoin icon already exposes the same
+ affordance via ``bim.unjoin_walls``."""
+
+ bl_idname = "OBJECT_GGT_bim_pair_disconnect"
+ bl_label = "Disconnect Pair Gizmo"
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "WINDOW"
+ bl_options = {"3D", "PERSISTENT"}
+
+ ICON_SCALE = 0.35
+
+ @classmethod
+ def poll(cls, context: bpy.types.Context) -> bool:
+ selected = list(tool.Blender.get_selected_objects())
+ if len(selected) != 2:
+ return False
+ if tool.Blender.Modifier.any_selected_is_array_child():
+ return False
+ elem_a = tool.Ifc.get_entity(selected[0])
+ elem_b = tool.Ifc.get_entity(selected[1])
+ if elem_a is None or elem_b is None:
+ return False
+ rels = tool.Connection.find_rels(elem_a, elem_b)
+ return any(kind == "element-top" for _, kind in rels)
+
+ def setup(self, context: bpy.types.Context) -> None:
+ default_color, highlight_color = self.get_decoration_colors()
+ self.disconnect_icon = self.setup_icon_gizmo(
+ "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
+ )
+ self.disconnect_icon.hide = True
+ self.disconnect_op = self.disconnect_icon.target_set_operator("bim.disconnect_elements")
+
+ def position_gizmos(self, context: bpy.types.Context) -> None:
+ self.disconnect_icon.hide = True
+ pair = _resolve_active_partner_pair(context)
+ if pair is None:
+ return
+ active, partner_obj, active_elem, partner_elem = pair
+ # Helper expects wall + slab regardless of which the user marked active.
+ if active_elem.is_a("IfcWall"):
+ wall_obj, slab_obj = active, partner_obj
+ elif partner_elem.is_a("IfcWall"):
+ wall_obj, slab_obj = partner_obj, active
+ else:
+ return
+ location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
+ if location is None:
+ return
+ billboard_rot = gizmo.get_billboard_rotation(context)
+ clearance = gizmo.top_down_clearance(context, billboard_rot)
+ self.disconnect_icon.matrix_basis = gizmo.billboarded_at(
+ location + clearance, billboard_rot, scale=self.ICON_SCALE
+ )
+ self.disconnect_icon.hide = False
+ self.disconnect_op.element_a_guid = active_elem.GlobalId
+ self.disconnect_op.element_b_guid = partner_elem.GlobalId
+ self.disconnect_icon.partner_obj = partner_obj
class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py
index e44dabd916..abe4f45113 100644
--- a/src/bonsai/bonsai/bim/module/model/workspace.py
+++ b/src/bonsai/bonsai/bim/module/model/workspace.py
@@ -963,7 +963,11 @@ class EditObjectUI:
@classmethod
def draw_regen_operations(cls, row, ui_context):
- if AuthoringData.data["is_regenable_element"]:
+ # ``AuthoringData.load`` flips ``is_loaded`` at entry as a recursion
+ # guard, so a partial load (any computation along the way raising)
+ # leaves the tail keys unset. ``.get()`` keeps the header draw alive
+ # until the underlying failure is investigated.
+ if AuthoringData.data.get("is_regenable_element"):
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
@@ -1317,7 +1321,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.recalculate_profile()
elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"):
bpy.ops.bim.recalculate_fill()
- elif self.active_class in ("IfcSpace"):
+ elif self.active_class in ("IfcSpace",):
bpy.ops.bim.generate_space()
def hotkey_S_M(self):
diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py
index 7243bd51b9..945cac5f66 100644
--- a/src/bonsai/bonsai/bim/module/project/__init__.py
+++ b/src/bonsai/bonsai/bim/module/project/__init__.py
@@ -30,7 +30,9 @@ classes = (
operator.BIM_FH_import_ifc,
operator.BIM_OT_apply_pending_opening_cuts,
operator.BIM_OT_dismiss_multi_instance_warning,
+ operator.BIM_OT_dismiss_pending_array_repair,
operator.BIM_OT_dismiss_pending_opening_cuts,
+ operator.BIM_OT_select_pending_array_repair,
operator.BIM_OT_select_pending_opening_cuts,
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
@@ -86,6 +88,7 @@ classes = (
prop.FilterCategory,
prop.Link,
prop.EditedObj,
+ prop.PendingArrayRepair,
prop.PendingOpeningRecut,
prop.BIMProjectProperties,
prop.MeasureToolSettings,
diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py
index 8401d8c113..31b1248785 100644
--- a/src/bonsai/bonsai/bim/module/project/operator.py
+++ b/src/bonsai/bonsai/bim/module/project/operator.py
@@ -1236,6 +1236,17 @@ class LoadProjectElements(bpy.types.Operator):
f"Apply manually from the Project panel.",
)
+ props.pending_array_repair.clear()
+ if ifc_importer.broken_arrays:
+ for element in ifc_importer.broken_arrays:
+ item = props.pending_array_repair.add()
+ item.ifc_definition_id = element.id()
+ self.report(
+ {"WARNING"},
+ f"{len(ifc_importer.broken_arrays)} array parent(s) reference missing child GUIDs. "
+ f"Inspect from the Project panel.",
+ )
+
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
@@ -3539,3 +3550,44 @@ class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
return {"FINISHED"}
+
+
+class BIM_OT_select_pending_array_repair(bpy.types.Operator):
+ bl_idname = "bim.select_pending_array_repair"
+ bl_label = "Select Array Parents With Missing Children"
+ bl_description = "Select the Blender objects of array parents whose BBIM_Array.Data references children that don't resolve in the file."
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context: bpy.types.Context) -> set[str]:
+ ifc_file = tool.Ifc.get()
+ if ifc_file is None:
+ self.report({"INFO"}, "No IFC file loaded.")
+ return {"CANCELLED"}
+ objects: list[bpy.types.Object] = []
+ for item in tool.Project.get_project_props().pending_array_repair:
+ try:
+ element = ifc_file.by_id(item.ifc_definition_id)
+ except RuntimeError:
+ continue
+ obj = tool.Ifc.get_object(element)
+ if obj is not None:
+ objects.append(obj)
+ if not objects:
+ self.report({"INFO"}, "No matching Blender objects found for the pending list.")
+ return {"CANCELLED"}
+ tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
+ self.report({"INFO"}, f"Selected {len(objects)} array parent(s).")
+ return {"FINISHED"}
+
+
+class BIM_OT_dismiss_pending_array_repair(bpy.types.Operator):
+ bl_idname = "bim.dismiss_pending_array_repair"
+ bl_label = "Dismiss Pending Array Repair"
+ bl_description = (
+ "Clear the pending array-repair list without acting on it. The underlying BBIM_Array.Data stays unchanged."
+ )
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context: bpy.types.Context) -> set[str]:
+ tool.Project.get_project_props().pending_array_repair.clear()
+ return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py
index e1214d4824..53b239ee6c 100644
--- a/src/bonsai/bonsai/bim/module/project/prop.py
+++ b/src/bonsai/bonsai/bim/module/project/prop.py
@@ -306,6 +306,17 @@ class PendingOpeningRecut(PropertyGroup):
ifc_definition_id: int
+class PendingArrayRepair(PropertyGroup):
+ """One array parent whose ``BBIM_Array.Data`` references at least one
+ child GUID that does not resolve in the current IFC file. The user can
+ select these parents from the Project panel banner to inspect them."""
+
+ ifc_definition_id: IntProperty(name="IFC Definition ID")
+
+ if TYPE_CHECKING:
+ ifc_definition_id: int
+
+
class BIMProjectProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
is_loading: BoolProperty(name="Is Loading", default=False)
@@ -372,6 +383,7 @@ class BIMProjectProperties(PropertyGroup):
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
)
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
+ pending_array_repair: CollectionProperty(name="Pending Array Repair", type=PendingArrayRepair)
style_limit: IntProperty(
name="Style Limit",
default=300,
@@ -538,6 +550,7 @@ class BIMProjectProperties(PropertyGroup):
angular_tolerance: float
void_limit: int
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
+ pending_array_repair: bpy.types.bpy_prop_collection_idprop[PendingArrayRepair]
style_limit: int
distance_limit: float
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py
index 0759679d42..a793dbc455 100644
--- a/src/bonsai/bonsai/bim/module/project/ui.py
+++ b/src/bonsai/bonsai/bim/module/project/ui.py
@@ -205,6 +205,20 @@ class BIM_PT_project(Panel):
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
+ if pending := pprops.pending_array_repair:
+ box = self.layout.box()
+ box.alert = True
+ box.label(text="Arrays With Missing Children", icon="ERROR")
+ draw_multiline_text(
+ box.column(align=True),
+ f"{len(pending)} array parent(s) reference child GUIDs that don't exist in this file. "
+ f"The arrays loaded incomplete. Select to inspect, or dismiss.",
+ context=context,
+ )
+ row = box.row(align=True)
+ row.operator("bim.select_pending_array_repair", text="Select Elements", icon="RESTRICT_SELECT_OFF")
+ row.operator("bim.dismiss_pending_array_repair", text="", icon="CANCEL")
+
if props.ifc_file:
self.draw_loaded_project_ui(context)
else:
diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py
index 84bff58927..ca1d176fda 100644
--- a/src/bonsai/bonsai/bim/ui.py
+++ b/src/bonsai/bonsai/bim/ui.py
@@ -517,6 +517,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
size=4,
description="Color of not selected verts/edges (used in profile editing mode)",
)
+ clip_box_cap_color: bpy.props.FloatVectorProperty(
+ name="Clip Box Caps Color",
+ subtype="COLOR",
+ default=(0.0, 0.0, 0.0, 1.0),
+ min=0.0,
+ max=1.0,
+ size=4,
+ description="Fill color of clip-box cross-section caps",
+ )
decorator_color_special: bpy.props.FloatVectorProperty(
name="Special Elements Color",
subtype="COLOR",
@@ -806,6 +815,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.row().prop(self, "decorator_color_special")
layout.row().prop(self, "decorator_color_error")
layout.row().prop(self, "decorator_color_background")
+ bonsai.bim.helper.draw_expandable_panel(
+ layout,
+ context,
+ "Clip Box",
+ self.draw_clip_box_colors,
+ )
+
+ def draw_clip_box_colors(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
+ layout.row().prop(self, "clip_box_cap_color")
def draw_default_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
box = layout.box()
diff --git a/src/bonsai/bonsai/core/connection.py b/src/bonsai/bonsai/core/connection.py
new file mode 100644
index 0000000000..71a2bb871c
--- /dev/null
+++ b/src/bonsai/bonsai/core/connection.py
@@ -0,0 +1,99 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Shared post-disconnect cleanup dispatch.
+
+Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the
+connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit
+disconnect-on-delete). Each rel kind returned by
+:py:meth:`bonsai.tool.connection.Connection.find_rels` /
+:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new
+rel kind means extending one dispatch table — both call sites benefit
+automatically and the AST forward-compat guard enforces coverage.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import bonsai.core.geometry
+from bonsai.core.model import regenerate_wall_to_underside
+
+if TYPE_CHECKING:
+ import bpy
+ import ifcopenshell
+
+ import bonsai.tool as tool
+
+
+def disconnect_rel(
+ ifc: "type[tool.Ifc]",
+ geometry: "type[tool.Geometry]",
+ model: "type[tool.Model]",
+ connection: "type[tool.Connection]",
+ rel: "ifcopenshell.entity_instance",
+ kind: str,
+ elem: "ifcopenshell.entity_instance",
+ partner: "ifcopenshell.entity_instance",
+ skip_elem_recreate: bool = False,
+ skip_partner_recreate: bool = False,
+) -> None:
+ """Run the post-disconnect cleanup for one rel.
+
+ ``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate``
+ flags suppress per-side regenerate / recreate work — used by the
+ cascade-on-delete to avoid re-extruding entities that are about to be
+ removed by ``remove_product``. For the disconnect operator (where neither
+ endpoint is being deleted), both flags stay False and the full cleanup
+ runs on both sides.
+ """
+ if kind == "path":
+ bonsai.core.geometry.remove_connection(geometry, connection=rel)
+ if not skip_elem_recreate:
+ elem_obj = ifc.get_object(elem)
+ if elem_obj is not None:
+ model.recreate_wall(elem, elem_obj)
+ if not skip_partner_recreate:
+ partner_obj = ifc.get_object(partner)
+ if partner_obj is not None:
+ model.recreate_wall(partner, partner_obj)
+ elif kind == "element-top":
+ wall, _slab = connection.orient_element_top(rel, elem, partner)
+ ifc.run(
+ "geometry.disconnect_element",
+ relating_element=rel.RelatingElement,
+ related_element=rel.RelatedElement,
+ )
+ # Skip the wall-side regenerate when the wall is itself being deleted —
+ # either it's the elem of this cascade pass, or it's the partner that
+ # was queued earlier in the same batch.
+ if (wall is elem and skip_elem_recreate) or (wall is partner and skip_partner_recreate):
+ return
+ wall_obj = ifc.get_object(wall)
+ if wall_obj is not None:
+ regenerate_wall_to_underside(ifc, geometry, model, [wall_obj])
+ elif kind == "element":
+ ifc.run(
+ "geometry.disconnect_element",
+ relating_element=rel.RelatingElement,
+ related_element=rel.RelatedElement,
+ )
+ else:
+ raise ValueError(f"Unknown rel kind: {kind!r}")
diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py
index 874675ea7f..30b1c9b515 100644
--- a/src/bonsai/bonsai/core/model.py
+++ b/src/bonsai/bonsai/core/model.py
@@ -167,12 +167,22 @@ def regenerate_wall_to_underside(
model: type[tool.Model],
wall_objs: list[bpy.types.Object],
) -> None:
- """Re-clip walls to their connected underside objects after the slab has moved."""
+ """Re-clip walls to their connected underside objects after the slab has moved.
+
+ When a wall has no remaining slab connections — the case reached after the
+ last TOP rel is severed (via disconnect or via cascade-on-slab-delete) — the
+ stale trim booleans are cleaned up so the wall reverts to its pre-clip
+ extrusion instead of holding orphan ``IfcBooleanResult`` items and a dead
+ ``BBIM_Boolean`` pset.
+ """
clipped_objs = []
+ reverted_objs = []
for obj in wall_objs:
wall = ifc.get_entity(obj)
slab_objs = model.get_connected_slab_objs(wall)
if not slab_objs:
+ model.remove_wall_to_underside_booleans(wall)
+ reverted_objs.append(obj)
continue
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
@@ -185,8 +195,9 @@ def regenerate_wall_to_underside(
if clip:
model.clip_wall_to_slab(wall, clip)
clipped_objs.append(obj)
- if clipped_objs:
- model.reload_body_representation(clipped_objs)
+ refresh_objs = clipped_objs + reverted_objs
+ if refresh_objs:
+ model.reload_body_representation(refresh_objs)
def extend_wall_to_slab(
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index f48165bd4c..91003174ad 100644
--- a/src/bonsai/bonsai/core/tool.py
+++ b/src/bonsai/bonsai/core/tool.py
@@ -195,6 +195,14 @@ class Collector:
def assign(cls, obj, should_clean_users_collection=False): pass
+@interface
+class Connection:
+ def find_rel(cls, elem_a, elem_b): pass
+ def find_rels(cls, elem_a, elem_b): pass
+ def find_rels_for_element(cls, elem): pass
+ def orient_element_top(cls, rel, elem_a, elem_b): pass
+
+
@interface
class Context:
def clear_context(cls): pass
@@ -694,11 +702,13 @@ class Model:
def load_openings(cls, openings): pass
def purge_scene_openings(cls): pass
def recalculate_walls(cls, objs): pass
+ def recreate_wall(cls, element, obj): pass
def regenerate_array(cls, parent, data): pass
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def remove_wall_to_underside_booleans(cls, wall): pass
+ def strip_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py
index 03716236e1..f927e5e1e1 100644
--- a/src/bonsai/bonsai/tool/__init__.py
+++ b/src/bonsai/bonsai/tool/__init__.py
@@ -30,7 +30,9 @@ from bonsai.tool.bsdd import Bsdd
from bonsai.tool.cad import Cad
from bonsai.tool.clash import Clash
from bonsai.tool.classification import Classification
+from bonsai.tool.clip_box import ClipBox
from bonsai.tool.collector import Collector
+from bonsai.tool.connection import Connection
from bonsai.tool.context import Context
from bonsai.tool.cost import Cost
from bonsai.tool.covering import Covering
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index 78b978849d..c468b0572e 100644
--- a/src/bonsai/bonsai/tool/blender.py
+++ b/src/bonsai/bonsai/tool/blender.py
@@ -30,7 +30,15 @@ import sys
import tempfile
import traceback
import types
-from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
+from collections.abc import (
+ Callable,
+ Generator,
+ Iterable,
+ Iterator,
+ Mapping,
+ Sequence,
+ Sized,
+)
from datetime import datetime
from functools import cache, lru_cache
from pathlib import Path
@@ -575,6 +583,120 @@ class Blender(bonsai.core.tool.Blender):
else:
decorator_cls.uninstall()
+ # Bonsai overrides Blender's default move/duplicate keymaps with macros
+ # that wrap TRANSFORM_OT_translate. While a macro is the outer modal
+ # entry, the inner TRANSFORM_OT_translate does not surface in
+ # window.modal_operators — the macro's own idname does. The ``BIM_OT_``
+ # prefix is what Blender returns from ``bl_idname`` at runtime (the
+ # class declaration uses the dotted ``bim.`` form).
+ BONSAI_TRANSFORM_MACROS: frozenset[str] = frozenset(
+ {
+ "BIM_OT_override_move_macro", # G key
+ "BIM_OT_override_object_duplicate_move_macro", # Shift+D
+ "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
+ "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
+ }
+ )
+
+ @classmethod
+ def is_transform_modal_active(cls, context: bpy.types.Context) -> bool:
+ """True iff a Blender transform modal (G/R/S and siblings, including
+ Bonsai's macro overrides) is currently driving per-frame
+ ``matrix_world`` updates. Reads ``window.modal_operators`` — the
+ Blender 4.2+ collection of running modal operators. Callers gate
+ per-frame side effects (gizmo positioning, IFC persistence, etc.)
+ on this so they don't fire during the drag.
+
+ Falls back to scanning every window in the window manager when
+ ``context.window`` is ``None`` — depsgraph callbacks run with a
+ limited context where ``context.window`` is typically missing,
+ but the modal is still active on one of the WM's windows.
+ """
+ window = getattr(context, "window", None)
+ if window is not None and getattr(window, "modal_operators", None):
+ windows = [window]
+ else:
+ wm = getattr(context, "window_manager", None) or bpy.context.window_manager
+ if wm is None:
+ return False
+ windows = list(wm.windows)
+ for w in windows:
+ modal_ops = getattr(w, "modal_operators", None)
+ if not modal_ops:
+ continue
+ for op in modal_ops:
+ idname = op.bl_idname
+ if idname.startswith("TRANSFORM_OT_") or idname in cls.BONSAI_TRANSFORM_MACROS:
+ return True
+ return False
+
+ @classmethod
+ def is_in_edit_mode(cls, context: Optional[bpy.types.Context] = None) -> bool:
+ """True iff the active object is in any edit-style mode.
+
+ Catches every ``EDIT_*`` variant (mesh, curve, armature,
+ metaball, lattice, surface, text, grease pencil). Defaults to
+ ``OBJECT`` when the mode attribute is missing so background-mode
+ callers (no UI context) don't false-positive.
+ """
+ ctx = context if context is not None else bpy.context
+ mode = getattr(ctx, "mode", "OBJECT")
+ return mode.startswith("EDIT_")
+
+ @classmethod
+ def iter_view3d_regions(cls) -> Iterator[tuple[bpy.types.Area, bpy.types.Region, bpy.types.RegionView3D]]:
+ """Yield ``(area, region, region_3d)`` for every WINDOW region in every 3D viewport.
+
+ Useful for features that need to act on every visible 3D viewport
+ (clip planes, draw handlers, region redraw fanout). Empty
+ generator when ``bpy.context.screen`` is unavailable (shutdown,
+ background mode without a screen).
+ """
+ screen = getattr(getattr(bpy, "context", None), "screen", None)
+ if screen is None:
+ return
+ for area in screen.areas:
+ if area.type != "VIEW_3D":
+ continue
+ for region in area.regions:
+ if region.type != "WINDOW":
+ continue
+ region_3d = getattr(region, "data", None)
+ if region_3d is None:
+ continue
+ yield area, region, region_3d
+
+ @classmethod
+ def get_or_create_collection(cls, scene: bpy.types.Scene, name: str) -> bpy.types.Collection:
+ """Return the named collection, creating + linking it to ``scene`` if absent."""
+ collection = bpy.data.collections.get(name)
+ if collection is None:
+ collection = bpy.data.collections.new(name)
+ scene.collection.children.link(collection)
+ return collection
+
+ @classmethod
+ def serialize_matrix(cls, matrix: Matrix) -> str:
+ """Serialize a 4x4 matrix as a 16-float comma-separated string.
+
+ Round-trip pair with :meth:`deserialize_matrix`. Used for storing
+ a matrix in an IFC pset string property without losing precision
+ (``%.9g`` carries ~9 significant digits, enough for ``float32``
+ round-trip).
+ """
+ return ",".join(f"{matrix[r][c]:.9g}" for r in range(4) for c in range(4))
+
+ @classmethod
+ def deserialize_matrix(cls, text: str) -> Matrix:
+ """Inverse of :meth:`serialize_matrix`."""
+ floats = [float(v) for v in text.split(",")]
+ return Matrix([tuple(floats[r * 4 : r * 4 + 4]) for r in range(4)])
+
+ @classmethod
+ def hash_matrix(cls, matrix: Matrix) -> int:
+ """Hash a 4x4 matrix by its 16 floats. Useful as a cache key."""
+ return hash(tuple(matrix[r][c] for r in range(4) for c in range(4)))
+
@classmethod
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
@@ -1274,7 +1396,10 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_object_from_guid(cls, guid: str) -> Union[bpy.types.Object, None]:
- element = tool.Ifc.get().by_guid(guid)
+ try:
+ element = tool.Ifc.get().by_guid(guid)
+ except RuntimeError:
+ return None
obj = tool.Ifc.get_object(element)
if obj:
return obj
diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py
index 957c8339fb..2a1f5f9c69 100644
--- a/src/bonsai/bonsai/tool/cad.py
+++ b/src/bonsai/bonsai/tool/cad.py
@@ -206,6 +206,237 @@ class Cad:
"""
return geometry.intersect_line_plane(v1, v2, plane_co, plane_no)
+ @classmethod
+ def obb_world_clip_planes(
+ cls,
+ center: Vector,
+ axes: tuple[Vector, Vector, Vector],
+ half_extents: Vector,
+ ) -> tuple[tuple[float, float, float, float], ...]:
+ """Return the 6 inward world clip planes of an oriented bounding box.
+
+ Each plane is a 4-tuple ``(a, b, c, d)`` for the equation
+ ``a*x + b*y + c*z + d``; a point is KEPT when the value is ``>= 0``
+ for every plane, matching ``RegionView3D.clip_planes`` semantics.
+ Return order is ``(+x, -x, +y, -y, +z, -z)`` where ``+x`` is the face
+ on the positive side of ``axes[0]``. ``axes`` are assumed orthonormal.
+ """
+ cx, cy, cz = center.x, center.y, center.z
+ planes: list[tuple[float, float, float, float]] = []
+ for i in range(3):
+ ux, uy, uz = axes[i].x, axes[i].y, axes[i].z
+ h = float(half_extents[i])
+ px, py, pz = cx + h * ux, cy + h * uy, cz + h * uz
+ nx, ny, nz = -ux, -uy, -uz
+ planes.append((nx, ny, nz, -(nx * px + ny * py + nz * pz)))
+ px, py, pz = cx - h * ux, cy - h * uy, cz - h * uz
+ planes.append((ux, uy, uz, -(ux * px + uy * py + uz * pz)))
+ return tuple(planes)
+
+ @classmethod
+ def obb_clip_planes_from_matrix(
+ cls,
+ matrix_world: Matrix,
+ expand: float = 0.0,
+ expand_rel: float = 0.0,
+ ) -> tuple[tuple[float, float, float, float], ...]:
+ """Return the 6 inward world clip planes for the unit cube under ``matrix_world``.
+
+ The implicit box is ``[-1, +1]^3`` in object-local space, so the
+ host's ``matrix_world`` translation is the world centre, its
+ rotation orients the box axes, and each column's magnitude is the
+ world half-extent along that local axis. ``expand`` (absolute
+ world units) and ``expand_rel`` (fraction of each axis's
+ half-extent) both add an outward margin — callers that visualise
+ the box with overlapping geometry (e.g. an empty CUBE display
+ sharing edges with the clip planes) pass non-zero values so the
+ box's own wireframe sits safely INSIDE the clip volume. Use the
+ relative form when the box is rendered at varying scales, since
+ the depth-buffer precision needed to keep an edge unclipped grows
+ with world-coordinate magnitude.
+ """
+ world_center = matrix_world.col[3].xyz
+ linear = matrix_world.to_3x3()
+ world_axes = []
+ world_half_list = []
+ for i in range(3):
+ v = linear.col[i].copy()
+ length = v.length
+ if length > 0.0:
+ world_axes.append(v / length)
+ else:
+ world_axes.append(Vector((0.0, 0.0, 0.0)))
+ world_half_list.append(length + expand + length * expand_rel)
+ return cls.obb_world_clip_planes(
+ world_center,
+ (world_axes[0], world_axes[1], world_axes[2]),
+ Vector(world_half_list),
+ )
+
+ @classmethod
+ def point_is_inside_clip_planes(
+ cls,
+ planes: tuple[tuple[float, float, float, float], ...],
+ point: Vector,
+ eps: float = 1e-6,
+ ) -> bool:
+ """True iff ``point`` is on the kept side of every plane (inclusive)."""
+ x, y, z = point.x, point.y, point.z
+ for a, b, c, d in planes:
+ if a * x + b * y + c * z + d < -eps:
+ return False
+ return True
+
+ @classmethod
+ def newell_normal(cls, points: Sequence) -> Vector:
+ """Newell's-method normal for a (possibly non-planar) 3D polygon ring.
+
+ Robust for thin / near-degenerate rings where a two-edge cross
+ product would be unstable.
+ """
+ nx = ny = nz = 0.0
+ n = len(points)
+ for i in range(n):
+ cur = points[i]
+ nxt = points[(i + 1) % n]
+ nx += (cur[1] - nxt[1]) * (cur[2] + nxt[2])
+ ny += (cur[2] - nxt[2]) * (cur[0] + nxt[0])
+ nz += (cur[0] - nxt[0]) * (cur[1] + nxt[1])
+ return Vector((nx, ny, nz))
+
+ @classmethod
+ def plane_basis(cls, points: Sequence) -> tuple[Vector, Vector]:
+ """Return an orthonormal ``(u, v)`` basis for the ring's best-fit plane."""
+ normal = cls.newell_normal(points)
+ if normal.length < 1e-12:
+ normal = Vector((0.0, 0.0, 1.0))
+ normal = normal.normalized()
+ ref = Vector((1.0, 0.0, 0.0))
+ if abs(normal.x) > 0.9:
+ ref = Vector((0.0, 1.0, 0.0))
+ u = normal.cross(ref)
+ if u.length < 1e-12:
+ ref = Vector((0.0, 0.0, 1.0))
+ u = normal.cross(ref)
+ u = u.normalized()
+ v = normal.cross(u).normalized()
+ return u, v
+
+ @classmethod
+ def tessellate_ring_planar(cls, polyline_list: list[list]) -> list[tuple[int, int, int]]:
+ """Triangulate ``[outer, *inners]`` 3D coord rings in their own plane.
+
+ Projects every ring onto the outer ring's best-fit plane and
+ returns ``(i, j, k)`` index triples into the flat
+ ``outer + inners[0] + inners[1] + ...`` vertex list. Falls
+ back to a shapely constrained Delaunay triangulation when
+ ``mathutils.geometry.tessellate_polygon`` silently leaves ring
+ vertices unused (its known failure mode on complex concave
+ polygons-with-holes).
+ """
+ from mathutils.geometry import tessellate_polygon
+
+ if not polyline_list or not polyline_list[0]:
+ return []
+ outer = polyline_list[0]
+ u, v = cls.plane_basis(outer)
+ origin = Vector(outer[0])
+
+ def _project_xy(ring):
+ return [((Vector(co) - origin).dot(u), (Vector(co) - origin).dot(v)) for co in ring]
+
+ projected_xy = [_project_xy(ring) for ring in polyline_list]
+ projected = [[Vector((x, y, 0.0)) for x, y in ring] for ring in projected_xy]
+ triangles = tessellate_polygon(projected)
+
+ n_total = sum(len(r) for r in projected_xy)
+ used = {i for tri in triangles for i in tri}
+ if triangles and len(used) >= n_total:
+ return triangles
+
+ fallback = cls._tessellate_via_shapely(projected_xy)
+ return fallback if fallback else triangles
+
+ @classmethod
+ def _tessellate_via_shapely(cls, projected_xy: list[list[tuple[float, float]]]) -> list[tuple[int, int, int]]:
+ """Constrained-Delaunay fallback for :meth:`tessellate_ring_planar`.
+
+ Honours the polygon's boundary AND holes. Returns ``[]`` when
+ shapely is unavailable or the polygon can't be cleaned via
+ ``buffer(0)``.
+ """
+ try:
+ from shapely.geometry import Polygon
+ except Exception:
+ return []
+ outer = projected_xy[0]
+ inners = projected_xy[1:]
+ if len(outer) < 3:
+ return []
+ try:
+ poly = Polygon(outer, inners)
+ poly = poly if poly.is_valid else poly.buffer(0)
+ if poly.is_empty:
+ return []
+ except Exception:
+ return []
+
+ flat = list(outer)
+ for r in inners:
+ flat.extend(r)
+
+ def _key(x, y):
+ return (round(x, 6), round(y, 6))
+
+ index_of: dict[tuple[float, float], int] = {}
+ for idx, (x, y) in enumerate(flat):
+ index_of.setdefault(_key(x, y), idx)
+
+ try:
+ from shapely import constrained_delaunay_triangles
+
+ res = constrained_delaunay_triangles(poly)
+ tri_geoms = list(getattr(res, "geoms", []) or [])
+ except Exception:
+ try:
+ from shapely.ops import triangulate
+
+ tri_geoms = [t for t in triangulate(poly) if poly.contains(t.representative_point())]
+ except Exception:
+ return []
+
+ out: list[tuple[int, int, int]] = []
+ for t in tri_geoms:
+ coords = list(t.exterior.coords)[:-1]
+ if len(coords) != 3:
+ continue
+ idxs = [index_of.get(_key(x, y)) for x, y in coords]
+ if any(i is None for i in idxs):
+ continue
+ out.append(tuple(idxs))
+ return out
+
+ @classmethod
+ def corners_might_cross_clip_planes(
+ cls,
+ planes: tuple[tuple[float, float, float, float], ...],
+ corners: Sequence[Vector],
+ ) -> bool:
+ """Conservative reject test: True if ``corners`` might cross the clip volume.
+
+ Returns False only when at least one plane has ALL corners on its
+ rejected side — meaning the convex hull of ``corners`` is fully
+ outside the clip volume and a per-mesh bisect can be skipped.
+ Returns True otherwise (possibly with false positives — never
+ false negatives), so callers always cap any object that actually
+ crosses the box. ``corners`` is typically the 8 world-space corners
+ of an object's bound box.
+ """
+ for a, b, c, d in planes:
+ if all(a * v.x + b * v.y + c * v.z + d < 0.0 for v in corners):
+ return False
+ return True
+
def intersect_edge_plane_v2(v1, v2, plane_co, plane_no, eps=1e-9):
"""
Numpy version of intersect_edge_plane
diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py
new file mode 100644
index 0000000000..1b30328a1d
--- /dev/null
+++ b/src/bonsai/bonsai/tool/clip_box.py
@@ -0,0 +1,991 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+from __future__ import annotations
+
+import contextlib
+from collections.abc import Callable, Iterator
+from typing import TYPE_CHECKING, Any, Optional
+
+import bpy
+
+import bonsai.tool as tool
+
+if TYPE_CHECKING:
+ from bonsai.bim.module.clip_box.prop import (
+ BIMClipBoxProperties,
+ BIMSceneClipBoxProperties,
+ )
+
+
+PlaneTuple = tuple[float, float, float, float]
+PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple]
+
+# Outward margin (world units) so the empty's CUBE display edges sit
+# safely INSIDE the clip volume. Absolute (not relative-to-scale)
+# because a relative multiplier balloons with scale and produces a
+# visibly-wrong gap between the wireframe and the clipped geometry.
+# Sub-mesh-precision value: visually invisible at any reasonable IFC
+# scale yet large enough to keep the empty's own wireframe edges off
+# the clip planes when float-precision accumulation pushes a corner
+# a fractional epsilon outward.
+_CLIP_EXPAND_ABS = 1e-6
+
+
+class ClipBox:
+ """Driver for the viewport clip-box feature.
+
+ Owns the bridge between ``BIMClipBoxProperties`` on a host empty and
+ Blender's ``RegionView3D.clip_planes`` machinery. Plane math is in
+ ``Cad``; this class is the bpy adapter.
+
+ Region-ownership: ``_owned`` tracks which regions we have armed so
+ a subsequent arm on the same region can skip the first-arm operator
+ path and write planes directly. Keyed by ``region.as_pointer()``.
+ """
+
+ _owned: set[int] = set()
+ _region_by_key: dict[int, tuple[Any, Any]] = {}
+ _refresh_pending: bool = False
+ _last_seen_ifc_id: int = 0
+ # Tracks the last matrix we persisted to the pset, keyed by Blender
+ # object name. Lets the depsgraph handler detect committed transform
+ # changes on clip boxes rehydrated from the project pset on file load
+ # (which have no modal poller watching them).
+ _persisted_matrices: dict[str, tuple] = {}
+ # Names of clip boxes whose matrix changed during a transform modal.
+ # Flushed when the gate flips back to inactive — one save per dirty
+ # box on commit, no writes during the drag.
+ _dirty_for_save: set[str] = set()
+ # Per-object cache of cross-section cap triangles in world space.
+ # Key: obj.name. Value: (cache_key_tuple, gpu_batch). Invalidated when
+ # the object's mesh data block, world matrix, or the clip box matrix
+ # changes. Rebuild is skipped while any transform modal is dragging
+ # matrix_world so a continuous G/R/S shows stale caps and rebuilds on
+ # commit instead of re-bisecting every mesh per frame.
+ _cap_cache: dict[str, tuple[tuple, Any]] = {}
+ _last_cap_clip_box_hash: int = 0
+ # Debounce window for cap rebuild from external (unknown-modal) drags:
+ # each depsgraph tick reschedules a timer this far in the future, so
+ # a burst of N ticks collapses to one rebuild after the storm.
+ _CAP_REBUILD_DEBOUNCE_SECONDS: float = 1.0
+ _last_modal_state: bool = False
+ _pending_cap_rebuild: Optional[Callable[[], None]] = None
+ # Per-object matrix hash baseline used to tell a real transform
+ # change from Blender's "selection touched the flag" noise: when a
+ # depsgraph tick reports is_updated_transform on an Object, the
+ # relevance filter compares the live hash against this baseline.
+ _last_seen_object_matrices: dict[str, int] = {}
+
+ @classmethod
+ def get_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> BIMSceneClipBoxProperties:
+ if scene is None:
+ scene = bpy.context.scene
+ return scene.BIMSceneClipBoxProperties
+
+ @classmethod
+ def get_object_props(cls, obj: bpy.types.Object) -> BIMClipBoxProperties:
+ return obj.BIMClipBoxProperties
+
+ @classmethod
+ def select_active_clip_box(cls, context: bpy.types.Context) -> None:
+ """Deselect everything, then select + activate the active clip box's empty.
+
+ Wired into the panel UIList's ``active_clip_box_index`` update
+ so clicking a row in the list does the standard outliner-style
+ focus: the user can immediately G/R/S the box they just picked.
+
+ Short-circuits when the active object is already the target —
+ keeps multi-selections intact when the index changed because the
+ depsgraph sync detected the user clicking the empty directly.
+ No-op when no active box is resolvable.
+ """
+ obj = cls.get_active_clip_box(context.scene)
+ if obj is None:
+ return
+ if getattr(context, "active_object", None) is obj:
+ return
+ tool.Blender.set_objects_selection(
+ context, active_object=obj, selected_objects=[obj], clear_previous_selection=True
+ )
+
+ @classmethod
+ def get_active_clip_box(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[bpy.types.Object]:
+ """Return the host empty of the currently active clip box, or ``None``."""
+ props = cls.get_scene_props(scene)
+ index = props.active_clip_box_index
+ if index < 0 or index >= len(props.clip_boxes):
+ return None
+ obj = props.clip_boxes[index].obj
+ if obj is None:
+ return None
+ obj_props = cls.get_object_props(obj)
+ if not obj_props.is_clip_box:
+ return None
+ return obj
+
+ @classmethod
+ def compute_planes(cls, obj: bpy.types.Object) -> PlaneSet:
+ """Build the 6 inward world clip planes from the host empty's matrix_world.
+
+ The empty's CUBE display spans local ``[-1, +1]^3`` (with
+ ``empty_display_size = 1``); ``matrix_world`` carries translation,
+ rotation, and per-axis scale, so the clip planes track the cube
+ exactly as it looks in the viewport. A tiny outward margin
+ prevents the cube's own wireframe from being clipped by its own
+ planes.
+ """
+ return tool.Cad.obb_clip_planes_from_matrix(obj.matrix_world, expand=_CLIP_EXPAND_ABS)
+
+ @classmethod
+ def compute_planes_from_matrix(cls, matrix: Any) -> PlaneSet:
+ """Same as :meth:`compute_planes` but accepts a raw matrix.
+
+ Used by the depsgraph handler to read the *evaluated* matrix during
+ a live G/R/S transform — that matrix reflects the in-progress
+ transform offset, while ``obj.matrix_world`` stays at the
+ pre-transform value until the operator commits on release.
+ """
+ return tool.Cad.obb_clip_planes_from_matrix(matrix, expand=_CLIP_EXPAND_ABS)
+
+ @classmethod
+ def apply_clip_planes(cls, planes: PlaneSet) -> None:
+ """Drive every open 3D viewport's clip planes to ``planes``.
+
+ Always calls ``view3d.clip_border`` to refresh the region's
+ ``clip_bb`` at the CURRENT view. Edit-mode click-select tests
+ against ``clip_local`` derived from that bbox; if we don't keep
+ ``clip_bb`` fresh, the user can orbit the view (or transform
+ the clip box) and find click-select rejecting verts that ARE
+ visible because the test is using a stale view-frustum bbox
+ captured the last time we armed. Re-arming on every commit
+ keeps the bbox aligned with the view the user is actually at.
+ """
+ for area, region, region_3d in tool.Blender.iter_view3d_regions():
+ key = region.as_pointer()
+ cls._owned.add(key)
+ cls._region_by_key[key] = (area, region)
+ cls._arm_region(area, region, region_3d, planes)
+
+ @classmethod
+ def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None:
+ """Initialize the region's clip machinery and write ``planes``.
+
+ ``view3d.clip_border`` with a FULL-REGION rect arms ``RV3D_CLIPPING``
+ without leaving the C-side ``clipbb`` degenerate (which would break
+ edit-mode click-select). Caller must guarantee a context in which
+ operators are legal (not a draw handler / depsgraph callback).
+ """
+ with bpy.context.temp_override(area=area, region=region):
+ bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height)
+ region_3d.clip_planes = planes
+ region_3d.use_clip_planes = True
+ region_3d.update()
+
+ @classmethod
+ def clear_clip_planes(cls) -> None:
+ """Disable clip planes on every 3D viewport region.
+
+ Unchecking ``enabled`` or removing a clip box turns clipping
+ off; any prior Alt+B clip is NOT restored. The ``_owned``
+ ownership table is preserved across this clear so a later
+ re-enable can skip the ``view3d.clip_border`` re-init (which
+ would re-derive ``clip_bb`` at the current view and break
+ edit-mode click-select alignment). The full ownership reset
+ happens only on IFC reload or addon unregister.
+ """
+ for area, region, region_3d in tool.Blender.iter_view3d_regions():
+ with contextlib.suppress(ReferenceError, AttributeError, TypeError):
+ region_3d.use_clip_planes = False
+ region.tag_redraw()
+
+ @classmethod
+ def _active_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[BIMSceneClipBoxProperties]:
+ """Scene PG iff the clipping pipeline should drive this tick, else ``None``.
+
+ Most sessions run with clipping disabled, so the cheap
+ ``enabled`` check fires before any active-box lookup or
+ per-mesh work. Callers compose with their own further checks
+ (e.g. ``show_caps`` for the cap pipeline) on the returned PG.
+ """
+ if scene is None:
+ scene = bpy.context.scene
+ scene_props = cls.get_scene_props(scene)
+ if not scene_props.enabled:
+ return None
+ return scene_props
+
+ @classmethod
+ def refresh(cls, scene: Optional[bpy.types.Scene] = None) -> None:
+ """Re-arm or clear the viewport clip based on the active clip box state."""
+ if cls._active_scene_props(scene) is None:
+ cls.clear_clip_planes()
+ return
+ obj = cls.get_active_clip_box(scene)
+ if obj is None:
+ cls.clear_clip_planes()
+ return
+ cls.apply_clip_planes(cls.compute_planes(obj))
+
+ @classmethod
+ def schedule_refresh(cls) -> None:
+ """Schedule a refresh on the next idle tick.
+
+ PropertyGroup ``update=`` callbacks must not call ``bpy.ops`` (which
+ ``apply_clip_planes`` may need for the first-time arm) — doing so
+ from within a property write disrupts gizmo modal accounting and
+ can leave the operator stack inconsistent. Deferring via a 0-delay
+ timer hands the refresh to Blender's main loop, where operators are
+ legal. Debounced: a flag suppresses repeats while one is pending.
+ """
+ if cls._refresh_pending:
+ return
+ cls._refresh_pending = True
+
+ def _do_refresh():
+ cls._refresh_pending = False
+ cls.refresh()
+ return None
+
+ bpy.app.timers.register(_do_refresh, first_interval=0.0)
+
+ @classmethod
+ def reset_ownership(cls) -> None:
+ """Drop the ownership table without touching any region. Used on register/reload."""
+ cls._owned.clear()
+ cls._region_by_key.clear()
+
+ PSET_NAME = "BBIM_ClipBoxes"
+ COLLECTION_NAME = "BBIM_ClipBoxes"
+
+ @classmethod
+ def _get_project_pset_entity(cls, create: bool = False):
+ """Return the ``IfcPropertySet`` entity holding the clip-box state.
+
+ Stored on ``IfcProject`` because IFC's IfcRoot pipeline locks and
+ strips object scale on export, which a clip box (whose size IS
+ its scale) cannot tolerate. A project-level pset side-steps any
+ per-entity placement sync.
+ """
+ import ifcopenshell.util.element
+
+ ifc_file = tool.Ifc.get()
+ if ifc_file is None:
+ return None
+ projects = ifc_file.by_type("IfcProject")
+ if not projects:
+ return None
+ project = projects[0]
+ existing = ifcopenshell.util.element.get_psets(project).get(cls.PSET_NAME)
+ if existing is not None:
+ return ifc_file.by_id(existing["id"])
+ if not create:
+ return None
+ return tool.Ifc.run("pset.add_pset", product=project, name=cls.PSET_NAME)
+
+ @classmethod
+ def mark_dirty_for_save(cls, obj_name: str) -> None:
+ """Note that ``obj_name`` has an unpersisted matrix change.
+
+ Accumulates dirty names during a transform drag without
+ touching the IFC graph; the flush gate writes exactly one
+ save per dirty box once no transform modal is active.
+ """
+ cls._dirty_for_save.add(obj_name)
+
+ @classmethod
+ def flush_pending_saves(cls, scene: Optional[bpy.types.Scene] = None) -> None:
+ """Write the pset iff there's pending dirt AND no transform modal.
+
+ Called from the depsgraph handler every tick. Reading
+ ``tool.Blender.is_transform_modal_active(bpy.context)`` checks
+ ``window.modal_operators`` against the known transform op names
+ (Blender vanilla + Bonsai macro overrides — kept centrally in
+ :attr:`tool.Blender.BONSAI_TRANSFORM_MACROS`), so this gate
+ survives any Bonsai keymap override and any Python script that
+ wraps the same operators.
+ """
+ if not cls._dirty_for_save:
+ return
+ if tool.Ifc.get() is None:
+ cls._dirty_for_save.clear()
+ return
+ if tool.Blender.is_transform_modal_active(bpy.context):
+ return
+ cls._dirty_for_save.clear()
+ cls.save_to_project_pset(scene)
+
+ @classmethod
+ def save_to_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None:
+ """Snapshot the active clip-box state to ``IfcProject.BBIM_ClipBoxes``.
+
+ Each clip box contributes ``Box__Name`` and ``Box__Matrix``
+ (a 16-float comma-separated string). ``Count`` is the canonical
+ size. ``enabled`` is intentionally not persisted — opening a file
+ should never silently hide geometry behind a remembered toggle.
+ No-op when no IFC file is loaded.
+ """
+ if tool.Ifc.get() is None:
+ return
+ if scene is None:
+ scene = bpy.context.scene
+ scene_props = cls.get_scene_props(scene)
+
+ pset = cls._get_project_pset_entity(create=True)
+ if pset is None:
+ return
+
+ properties: dict[str, str | int] = {
+ "Count": len(scene_props.clip_boxes),
+ "ShowCaps": int(scene_props.show_caps),
+ }
+ for i, entry in enumerate(scene_props.clip_boxes):
+ obj = entry.obj
+ if obj is None:
+ continue
+ properties[f"Box_{i}_Name"] = obj.name
+ properties[f"Box_{i}_Matrix"] = tool.Blender.serialize_matrix(obj.matrix_world)
+ tool.Ifc.run("pset.edit_pset", pset=pset, properties=properties)
+
+ @classmethod
+ def load_from_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None:
+ """Rehydrate clip boxes from ``IfcProject.BBIM_ClipBoxes``.
+
+ Idempotent: drops stale list entries (deleted hosts / un-flagged
+ objects), then for each saved box, creates the empty if absent
+ or updates its matrix if the .blend reload already restored it.
+
+ ``scene_props.enabled`` is NOT touched: it defaults to ``False``
+ (so a fresh IFC load over a fresh .blend never silently hides
+ geometry), and Blender's normal .blend persistence carries the
+ user's saved toggle through .blend reload.
+ """
+ import ifcopenshell.util.element
+
+ if scene is None:
+ scene = bpy.context.scene
+ scene_props = cls.get_scene_props(scene)
+
+ for index in range(len(scene_props.clip_boxes) - 1, -1, -1):
+ entry = scene_props.clip_boxes[index]
+ obj = entry.obj
+ if obj is None or not cls.get_object_props(obj).is_clip_box:
+ scene_props.clip_boxes.remove(index)
+
+ ifc_file = tool.Ifc.get()
+ if ifc_file is None:
+ return
+ projects = ifc_file.by_type("IfcProject")
+ if not projects:
+ return
+ pset = ifcopenshell.util.element.get_psets(projects[0]).get(cls.PSET_NAME)
+ if not pset:
+ return
+
+ show_caps_raw = pset.get("ShowCaps")
+ if show_caps_raw is not None:
+ scene_props.show_caps = bool(int(show_caps_raw))
+ # enabled is intentionally NOT read from the pset — see docstring.
+
+ existing_by_name = {entry.obj.name: entry.obj for entry in scene_props.clip_boxes if entry.obj}
+ existing_objs = set(existing_by_name.values())
+ count = int(pset.get("Count", 0) or 0)
+ for i in range(count):
+ name = pset.get(f"Box_{i}_Name") or f"ClipBox.{i:03d}"
+ matrix_str = pset.get(f"Box_{i}_Matrix")
+ if not matrix_str:
+ continue
+ matrix = tool.Blender.deserialize_matrix(matrix_str)
+ existing_obj = bpy.data.objects.get(name)
+ if existing_obj is None:
+ # Fresh IFC load: no .blend backing, no viewport clip state.
+ # Create the empty, place it, and force enabled=False so we
+ # don't silently hide geometry behind a box the user forgot.
+ obj = bpy.data.objects.new(name, None)
+ obj.empty_display_type = "CUBE"
+ obj.empty_display_size = 1.0
+ obj.show_in_front = True
+ collection = tool.Blender.get_or_create_collection(scene, cls.COLLECTION_NAME)
+ collection.objects.link(obj)
+ obj.matrix_world = matrix
+ cls.get_object_props(obj).is_clip_box = True
+ else:
+ # .blend reload: the empty (and the scene-level enabled
+ # toggle) survived Blender's own session save. Update the
+ # matrix in case the pset diverged from the .blend snapshot.
+ obj = existing_obj
+ obj.matrix_world = matrix
+ cls.get_object_props(obj).is_clip_box = True
+ if obj not in existing_objs:
+ entry = scene_props.clip_boxes.add()
+ entry.obj = obj
+ existing_objs.add(obj)
+
+ if scene_props.clip_boxes and scene_props.active_clip_box_index >= len(scene_props.clip_boxes):
+ scene_props.active_clip_box_index = 0
+
+ @classmethod
+ def apply_clip_planes_direct(cls, planes: PlaneSet) -> None:
+ """Direct-write variant for contexts where ``bpy.ops`` is illegal.
+
+ Skips the first-arm path (which needs ``view3d.clip_border``)
+ and writes planes directly to every armed region.
+ ``region_3d.update()`` pushes the new clip planes to the GPU
+ buffer the rasteriser samples — without it the planes sit in
+ the data block and the next frame still uses the previous GPU
+ state. ``tag_redraw`` requests that the region actually
+ redraws this frame.
+ """
+ for area, region, region_3d in tool.Blender.iter_view3d_regions():
+ if not region_3d.use_clip_planes:
+ continue
+ key = region.as_pointer()
+ cls._region_by_key[key] = (area, region)
+ region_3d.clip_planes = planes
+ region_3d.update()
+ region.tag_redraw()
+
+ @classmethod
+ def _sync_collection_to_list(cls, scene: bpy.types.Scene) -> None:
+ """Add any clip-box-flagged empties not yet in ``scene_props.clip_boxes``.
+
+ Bonsai's duplicate-move macros (Shift+D, Alt+D, Ctrl+Shift+D)
+ deep-copy the source's ``BIMClipBoxProperties``, so the
+ duplicated empty carries ``is_clip_box=True`` but no scene-list
+ entry exists for it. This sync turns the duplicate into a
+ first-class clip box matching the UIList duplicate button: a
+ new entry, set active, persisted to the pset.
+
+ Scoped to the ``BBIM_ClipBoxes`` collection so the cost is O(N)
+ in the number of clip boxes, not O(N) in the whole scene.
+ """
+ scene_props = cls.get_scene_props(scene)
+ known_objs = {entry.obj for entry in scene_props.clip_boxes if entry.obj}
+ collection = bpy.data.collections.get(cls.COLLECTION_NAME)
+ if collection is None:
+ return
+ appended = False
+ for obj in collection.objects:
+ if obj in known_objs:
+ continue
+ if obj.type != "EMPTY":
+ continue
+ obj_props = cls.get_object_props(obj)
+ if not obj_props.is_clip_box:
+ continue
+ entry = scene_props.clip_boxes.add()
+ entry.obj = obj
+ scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
+ appended = True
+ if appended and tool.Ifc.get() is not None:
+ cls.save_to_project_pset(scene)
+
+ @classmethod
+ def on_depsgraph_update(cls, scene, depsgraph) -> None:
+ """Safety-net re-arm, IFC-load rehydrate, sync + pset persistence.
+
+ - **Shutdown guard**: skips when ``bpy.context.screen`` is ``None``
+ so the persistent handler can't fault against freed UI memory.
+ - **IFC reload detection**: when ``id(tool.Ifc.get())`` changes,
+ drop the now-stale ``_owned`` table (the regions from the old
+ screen were freed) and rehydrate clip boxes from the new
+ project's ``BBIM_ClipBoxes`` pset.
+ - **Collection-to-list sync**: catches clip-box empties created
+ outside ``bim.add_clip_box`` / ``bim.duplicate_clip_box`` —
+ notably Bonsai's Shift+D / Alt+D / Ctrl+Shift+D macros, which
+ deep-copy the source's ``BIMClipBoxProperties`` (including
+ ``is_clip_box=True``) but don't register the copy with us.
+ Detection lives here so any future entry path is handled too.
+ - **Live preview safety net**: re-applies the clip planes from
+ the active box's evaluated matrix. ``on_pre_view`` is the
+ primary live-preview path; this is what catches matrix changes
+ outside any modal (Python set, undo, constraint update).
+ - **Pset persistence**: when ``obj.matrix_world`` differs from
+ the last persisted snapshot, write it to the project pset.
+ Blender's G/R/S modal only commits ``matrix_world`` on release,
+ so this branch fires once per commit — exactly the cadence the
+ user expects for "save my latest transform".
+ """
+ if getattr(bpy.context, "screen", None) is None:
+ return
+ if cls._active_scene_props(scene) is None:
+ return
+ ifc_file = tool.Ifc.get()
+ ifc_id = id(ifc_file) if ifc_file is not None else 0
+ if ifc_id != cls._last_seen_ifc_id:
+ cls._last_seen_ifc_id = ifc_id
+ cls._owned.clear()
+ cls._region_by_key.clear()
+ cls._persisted_matrices.clear()
+ cls._last_seen_object_matrices.clear()
+ if ifc_file is not None:
+ cls.load_from_project_pset(scene)
+ # Orphan-empty adoption is deferred while a transform modal is
+ # dragging so the active-index change on adoption can't disrupt
+ # the move.
+ if not tool.Blender.is_transform_modal_active(bpy.context):
+ cls._sync_collection_to_list(scene)
+ obj = cls.get_active_clip_box(scene)
+ if obj is None:
+ return
+
+ current_matrix = tuple(tuple(row) for row in obj.matrix_world)
+ prev_matrix = cls._persisted_matrices.get(obj.name)
+ if prev_matrix != current_matrix:
+ cls._persisted_matrices[obj.name] = current_matrix
+ # Only persist when an IFC file is loaded; otherwise the box
+ # is purely Blender-side and there's nothing to write to.
+ # Mark dirty here, FLUSH below — the gate suppresses writes
+ # while a transform modal is dragging so one drag produces
+ # one save on release, not N saves per frame.
+ if ifc_file is not None and prev_matrix is not None:
+ cls.mark_dirty_for_save(obj.name)
+ cls.flush_pending_saves(scene)
+
+ try:
+ eval_obj = obj.evaluated_get(depsgraph)
+ matrix = eval_obj.matrix_world
+ except (AttributeError, RuntimeError, ReferenceError):
+ return
+ cls.apply_clip_planes_direct(cls.compute_planes_from_matrix(matrix))
+
+ @classmethod
+ def on_pre_view(cls) -> None:
+ """Per-redraw live preview hook.
+
+ Installed as a ``SpaceView3D.draw_handler_add`` at ``PRE_VIEW``.
+ Reads the active clip box's evaluated matrix and writes the
+ clip planes to ``bpy.context.region_data`` — the region being
+ rendered THIS frame, so no ``temp_override`` is needed.
+
+ IFC pset writes are NOT performed here; that's the depsgraph
+ handler's job (it fires on transform commit and writes through
+ the operator transaction path).
+ """
+ if cls._active_scene_props() is None:
+ return
+ obj = cls.get_active_clip_box()
+ if obj is None:
+ return
+ region_3d = getattr(bpy.context, "region_data", None)
+ if region_3d is None or not region_3d.use_clip_planes:
+ return
+ try:
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ matrix = obj.evaluated_get(depsgraph).matrix_world
+ except (AttributeError, RuntimeError, ReferenceError):
+ return
+ region_3d.clip_planes = cls.compute_planes_from_matrix(matrix)
+ region_3d.update()
+
+ # ------------------------------------------------------------------
+ # Cross-section caps
+ #
+ # When the clip box is enabled, each IfcProduct mesh that crosses
+ # the box gets a "cap" polygon drawn where its geometry intersects
+ # a clip plane — so cut surfaces appear filled instead of hollow.
+ # The pipeline (``bmesh.ops.bisect_plane(clear_outer=True)`` per
+ # plane, then ``bmesh.ops.contextual_create`` to fill cut edges)
+ # runs on a temp BMesh per object so the source mesh is untouched.
+ # ------------------------------------------------------------------
+
+ @classmethod
+ def _compute_caps_for_object(
+ cls,
+ obj: bpy.types.Object,
+ world_planes: PlaneSet,
+ depsgraph: Optional[Any] = None,
+ ) -> list[tuple[float, float, float]]:
+ """Return triangle vertices for ``obj``'s cap polygons.
+
+ Flat list of ``(x, y, z)`` tuples in world space, ready for a
+ ``batch_for_shader("TRIS", ...)`` upload. Empty when the
+ object's bound box doesn't cross any clip plane.
+
+ Uses the evaluated mesh (modifier stack applied) when a
+ ``depsgraph`` is passed, so caps match the rendered geometry of
+ objects with subsurf / boolean / mirror modifiers. Falls back to
+ ``obj.data`` only for callers without a depsgraph (e.g. unit
+ tests that fabricate a mesh outside any eval context).
+ """
+ import bmesh
+ from mathutils import Vector
+
+ bm = bmesh.new()
+ eval_obj = None
+ try:
+ if depsgraph is not None:
+ try:
+ eval_obj = obj.evaluated_get(depsgraph)
+ mesh = eval_obj.to_mesh()
+ bm.from_mesh(mesh)
+ except (RuntimeError, ReferenceError):
+ return []
+ else:
+ try:
+ bm.from_mesh(obj.data)
+ except (RuntimeError, ReferenceError):
+ return []
+
+ ws_to_ls = obj.matrix_world.inverted_safe()
+ rot = ws_to_ls.to_quaternion()
+ planes_local = []
+ for plane in world_planes:
+ inward_world = Vector(plane[:3])
+ d = plane[3]
+ point_on_plane_world = inward_world * -d
+ plane_co_local = ws_to_ls @ point_on_plane_world
+ # bisect_plane removes the +plane_no side when clear_outer=True;
+ # our inward normal points INTO the box, so we negate to clear
+ # the box's outside.
+ plane_no_local = (rot @ -inward_world).normalized()
+ planes_local.append((plane_co_local, plane_no_local))
+
+ cap_layer = tool.Geometry.bisect_and_cap(bm, planes_local)
+ if cap_layer is None:
+ return []
+
+ cap_faces = [f for f in bm.faces if f.is_valid and f[cap_layer]]
+ if not cap_faces:
+ return []
+
+ mw = obj.matrix_world
+ return cls._triangulate_cap_faces(cap_faces, mw)
+ finally:
+ bm.free()
+ if eval_obj is not None:
+ with contextlib.suppress(RuntimeError, ReferenceError, AttributeError):
+ eval_obj.to_mesh_clear()
+
+ @classmethod
+ def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]:
+ """Yield mesh objects eligible for capping: visible ``IfcElement``s.
+
+ Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so
+ spatial structure (``IfcSpace``, ``IfcBuildingStorey``,
+ ``IfcSite``) and annotations / grids never get capped — they're
+ non-physical containers / overlays that shouldn't sprout solid
+ fill polygons at clip boundaries.
+ """
+ ifc_file = tool.Ifc.get()
+ if ifc_file is None:
+ return
+ for obj in scene.objects:
+ if obj.type != "MESH" or obj.data is None:
+ continue
+ if not obj.visible_get():
+ continue
+ entity = tool.Ifc.get_entity(obj)
+ if entity is None or not entity.is_a("IfcElement"):
+ continue
+ yield obj
+
+ @classmethod
+ def rebuild_cap_cache(
+ cls,
+ scene: Optional[bpy.types.Scene] = None,
+ depsgraph: Optional[Any] = None,
+ ) -> None:
+ """Recompute the per-object cap-vertex cache from the active clip box.
+
+ No-op while a transform modal is dragging ``matrix_world`` — the
+ existing cache stays in place and the user sees stale caps until
+ the drag commits. Per-object cache entries are reused when the
+ object's mesh, world matrix, and the clip-box matrix all match
+ the prior key. Stale entries (deleted objects, disabled box,
+ unloaded IFC) are pruned.
+
+ When ``depsgraph`` is supplied (the typical handler path), per-mesh
+ caps are computed from the evaluated mesh so modifier stacks are
+ honoured; without it, raw source meshes are used.
+ """
+ scene_props = cls._active_scene_props(scene)
+ if scene_props is None or not scene_props.show_caps:
+ cls._cap_cache.clear()
+ cls._last_cap_clip_box_hash = 0
+ return
+ if scene is None:
+ scene = bpy.context.scene
+ active = cls.get_active_clip_box(scene)
+ if active is None:
+ cls._cap_cache.clear()
+ cls._last_cap_clip_box_hash = 0
+ return
+ if tool.Blender.is_transform_modal_active(bpy.context):
+ return
+
+ # Cap with the SAME expanded planes the viewport clips against
+ # (cls.compute_planes applies the _CLIP_EXPAND_ABS margin), so the
+ # cap face lines up with the visible cut. Using the un-expanded
+ # planes would leave a visible margin-sized gap between the cut
+ # mesh edge and the cap.
+ world_planes = cls.compute_planes(active)
+ clip_box_hash = hash(world_planes)
+ cls._last_cap_clip_box_hash = clip_box_hash
+
+ from mathutils import Vector
+
+ live_names: set[str] = set()
+ for obj in cls._iter_capable_objects(scene):
+ live_names.add(obj.name)
+ mesh = obj.data
+ cache_key = (
+ getattr(mesh, "session_uid", id(mesh)),
+ tool.Blender.hash_matrix(obj.matrix_world),
+ clip_box_hash,
+ )
+ cached = cls._cap_cache.get(obj.name)
+ if cached is not None and cached[0] == cache_key:
+ continue
+ # Cheap AABB-vs-clip-box rejection before the expensive bisect.
+ # bound_box has 8 corners in object-local space — transform to
+ # world and check whether they're all on the outside of any
+ # clip plane. If so, the mesh can't produce a cap from this
+ # box and we skip the per-mesh bisect.
+ mw = obj.matrix_world
+ world_corners = [mw @ Vector(c) for c in obj.bound_box]
+ if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners):
+ cls._cap_cache[obj.name] = (cache_key, None)
+ continue
+ verts = cls._compute_caps_for_object(obj, world_planes, depsgraph=depsgraph)
+ batch = cls._build_cap_batch(verts) if verts else None
+ cls._cap_cache[obj.name] = (cache_key, batch)
+
+ for name in list(cls._cap_cache):
+ if name not in live_names:
+ cls._cap_cache.pop(name)
+ for name in list(cls._last_seen_object_matrices):
+ if name not in live_names:
+ cls._last_seen_object_matrices.pop(name)
+
+ @staticmethod
+ def _build_cap_batch(verts: list[tuple[float, float, float]]):
+ """Bake ``verts`` into a GPU ``TRIS`` batch bound to ``UNIFORM_COLOR``."""
+ import gpu
+ from gpu_extras.batch import batch_for_shader
+
+ shader = gpu.shader.from_builtin("UNIFORM_COLOR")
+ return batch_for_shader(shader, "TRIS", {"pos": verts})
+
+ @classmethod
+ def _triangulate_cap_faces(cls, cap_faces, mw) -> list[tuple[float, float, float]]:
+ """Triangulate cap faces and return world-space triangle vertices.
+
+ Each cap face is tessellated as a single simple ring via
+ :meth:`tool.Cad.tessellate_ring_planar`. Nested cap polygons
+ (hollow profiles — annular columns, pipe walls) render as solid
+ discs in v1; proper polygon-with-holes triangulation is a known
+ limitation and a follow-up.
+ """
+ verts: list[tuple[float, float, float]] = []
+ for face in cap_faces:
+ if not face.is_valid or len(face.verts) < 3:
+ continue
+ ring = [v.co.copy() for v in face.verts]
+ try:
+ tri_indices = tool.Cad.tessellate_ring_planar([ring])
+ except Exception:
+ continue
+ for i, j, k in tri_indices:
+ for idx in (i, j, k):
+ w = mw @ ring[idx]
+ verts.append((w.x, w.y, w.z))
+ return verts
+
+ @classmethod
+ def on_depsgraph_update_caps(cls, scene, depsgraph) -> None:
+ """Depsgraph entry-point — guard, then delegate to the
+ modal-aware debounce in :meth:`_handle_cap_tick`."""
+ if getattr(bpy.context, "screen", None) is None:
+ return
+ if cls._active_scene_props(scene) is None:
+ return
+ # Edit mode (mesh / curve / armature / …) fires depsgraph
+ # constantly as the user manipulates verts/edges; the cap view
+ # isn't the focus of that work, and the caps would flash off on
+ # every nudge. Skip scheduling entirely while in any edit mode.
+ if tool.Blender.is_in_edit_mode():
+ return
+ cls._handle_cap_tick(scene, depsgraph)
+
+ @classmethod
+ def _handle_cap_tick(cls, scene, depsgraph) -> None:
+ """Schedule (or immediately fire) a cap-cache rebuild.
+
+ Strategy:
+ - Default: debounce. Each depsgraph tick reschedules a
+ ``bpy.app.timers`` callback ``_CAP_REBUILD_DEBOUNCE_SECONDS``
+ in the future, so a burst of ticks from an unknown-to-Bonsai
+ drag (external-addon gizmo, scripted property updates) collapses
+ to a single rebuild after the storm subsides. Drag is smooth,
+ caps catch up shortly after release.
+ - Fast path: when a *known* transform modal (Bonsai G/R/S) just
+ finished — detected as a True→False transition on
+ ``is_transform_modal_active`` — cancel any pending timer and
+ rebuild immediately, preserving the snappy on-release feel for
+ Bonsai-internal drags.
+ - Skip path: depsgraph ticks fire for selection-only changes,
+ UI events, undo writes, etc. — none of which can move a cap.
+ When no update in the tick carries ``is_updated_geometry`` or
+ ``is_updated_transform``, return without scheduling so the
+ cache and its hide-while-pending gate don't churn for free.
+ """
+ is_modal = tool.Blender.is_transform_modal_active(bpy.context)
+ modal_just_ended = cls._last_modal_state and not is_modal
+ cls._last_modal_state = is_modal
+
+ if modal_just_ended:
+ cls._cancel_pending_cap_rebuild()
+ cls.rebuild_cap_cache(scene, depsgraph=depsgraph)
+ return
+
+ if depsgraph is not None and not cls._depsgraph_has_relevant_changes(depsgraph):
+ return
+
+ cls._schedule_cap_rebuild()
+
+ @classmethod
+ def _depsgraph_has_relevant_changes(cls, depsgraph) -> bool:
+ """True iff the tick carries an Object geometry change, or an
+ Object transform update whose ``matrix_world`` actually moved.
+
+ Blender raises ``is_updated_transform`` on the selected Object
+ itself even for plain selection changes (no matrix delta), and
+ on Scene / ViewLayer IDs for the same. We'd schedule (and hide
+ caps for) every click without this check. Comparing a matrix
+ hash against a per-object baseline filters selection noise
+ without requiring opt-in from external addons.
+
+ First time we see an Object the hash is recorded as baseline
+ (no flag), so an addon-load-time selection burst doesn't fire
+ a phantom rebuild; subsequent real moves are detected on the
+ first tick the matrix actually differs.
+ """
+ relevant = False
+ for upd in depsgraph.updates:
+ obj = upd.id
+ if not isinstance(obj, bpy.types.Object):
+ continue
+ if upd.is_updated_geometry:
+ relevant = True
+ continue
+ if not upd.is_updated_transform:
+ continue
+ new_hash = tool.Blender.hash_matrix(obj.matrix_world)
+ old_hash = cls._last_seen_object_matrices.get(obj.name)
+ cls._last_seen_object_matrices[obj.name] = new_hash
+ if old_hash is not None and old_hash != new_hash:
+ relevant = True
+ return relevant
+
+ @classmethod
+ def _schedule_cap_rebuild(cls) -> None:
+ """(Re)schedule the deferred cap rebuild.
+
+ Each call cancels any pending timer and registers a fresh one
+ so a burst of updates collapses to a single rebuild once the
+ debounce window of quiet elapses.
+ """
+ cls._cancel_pending_cap_rebuild()
+
+ def _do_rebuild() -> None:
+ cls._pending_cap_rebuild = None
+ try:
+ cls.rebuild_cap_cache()
+ except Exception:
+ # bpy.app.timers swallows exceptions silently, leaving
+ # the user with stale caps + no diagnostic. Surface to
+ # the console so future bisect / cap edge cases are
+ # debuggable instead of mysteriously invisible.
+ import traceback
+
+ traceback.print_exc()
+ # Timer fires from the main loop without an accompanying
+ # depsgraph tick, so the viewport won't repaint on its own;
+ # nudge every region so the freshly-baked cap batches show
+ # up without the user having to wiggle the mouse.
+ for _area, region, _region_3d in tool.Blender.iter_view3d_regions():
+ region.tag_redraw()
+ return None
+
+ bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS)
+ cls._pending_cap_rebuild = _do_rebuild
+
+ @classmethod
+ def _cancel_pending_cap_rebuild(cls) -> None:
+ """Cancel any pending debounced rebuild so the next event source
+ gets a clean slate. Idempotent and safe to call when none is
+ registered (e.g. on addon unregister)."""
+ pending = cls._pending_cap_rebuild
+ if pending is not None and bpy.app.timers.is_registered(pending):
+ bpy.app.timers.unregister(pending)
+ cls._pending_cap_rebuild = None
+
+ @classmethod
+ def on_post_view_caps(cls) -> None:
+ """Draw cached cap batches over the clipped geometry.
+
+ Installed as a ``SpaceView3D.draw_handler_add`` at ``POST_VIEW``.
+ Caps render with depth-test + depth-write enabled so any
+ geometry in front of the cap occludes it — without this the
+ ``UNIFORM_COLOR`` shader defaults to no-depth and the caps
+ would always paint on top of the scene. One ``batch.draw`` per
+ object; batches are pre-baked.
+ """
+ if not cls._cap_cache:
+ return
+ scene_props = cls._active_scene_props()
+ if scene_props is None or not scene_props.show_caps:
+ return
+ # Hide caps while in edit mode — the user's focus is on
+ # vert/edge/face manipulation, not the section view; the cache
+ # is also frozen by the same gate in the depsgraph path.
+ if tool.Blender.is_in_edit_mode():
+ return
+ # Hide caps for the duration of any G/R/S to suppress mid-drag
+ # visual jitter; the cache is also frozen by the same gate so
+ # anything drawn here would be stale relative to the live mesh.
+ if tool.Blender.is_transform_modal_active(bpy.context):
+ return
+ # Hide caps while a debounced rebuild is in flight (typical
+ # cause: external-addon gizmo drag). The cache may reflect a
+ # frame from earlier in the drag; drawing it would look stale
+ # against the geometry the user is currently mutating.
+ if cls._pending_cap_rebuild is not None:
+ return
+ import gpu
+
+ prefs = tool.Blender.get_addon_preferences()
+ cap_color = tuple(prefs.clip_box_cap_color)
+ shader = gpu.shader.from_builtin("UNIFORM_COLOR")
+ shader.bind()
+ shader.uniform_float("color", cap_color)
+ prev_depth_test = gpu.state.depth_test_get()
+ prev_depth_mask = gpu.state.depth_mask_get()
+ gpu.state.depth_test_set("LESS_EQUAL")
+ gpu.state.depth_mask_set(True)
+ try:
+ for _key, batch in cls._cap_cache.values():
+ if batch is None:
+ continue
+ batch.draw(shader)
+ finally:
+ gpu.state.depth_mask_set(prev_depth_mask)
+ gpu.state.depth_test_set(prev_depth_test)
diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py
new file mode 100644
index 0000000000..4ec154b573
--- /dev/null
+++ b/src/bonsai/bonsai/tool/connection.py
@@ -0,0 +1,150 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Generic discovery of the relation linking two IFC elements.
+
+Used by ``bim.disconnect_elements`` so the operator surface is one operator
+per disconnect intent (active vs. partner, identified by GlobalId) rather
+than one per rel class. The kind label returned alongside the rel lets the
+operator dispatch the right post-disconnect cleanup:
+
+- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.)
+- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"``
+ (the rel kind ``extend_walls_to_underside`` creates)
+- ``"element"`` for any other ``IfcRelConnectsElements``
+
+Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect
+operator's cleanup switch maps each kind to the right post-mutation calls."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import ifcopenshell
+
+
+class Connection:
+ @classmethod
+ def find_rels(
+ cls,
+ elem_a: "ifcopenshell.entity_instance",
+ elem_b: "ifcopenshell.entity_instance",
+ ) -> "list[tuple[ifcopenshell.entity_instance, str]]":
+ """Return every supported rel linking ``elem_a`` to ``elem_b`` as a
+ list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and
+ ``ConnectedFrom`` because either side of the rel can be the relating
+ element, and the same pair may carry rels authored with opposite
+ orientations (``disconnect_path``'s ``(relating, related)`` mode only
+ inspects ``relating.ConnectedTo``, so a single call would miss the
+ opposite-orientation rel)."""
+ rels: list[tuple[ifcopenshell.entity_instance, str]] = []
+ seen: set[int] = set()
+
+ def _record(rel, kind):
+ if rel.id() not in seen:
+ seen.add(rel.id())
+ rels.append((rel, kind))
+
+ for rel in getattr(elem_a, "ConnectedTo", []) or ():
+ if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatedElement", None) == elem_b:
+ _record(rel, "path")
+ for rel in getattr(elem_a, "ConnectedFrom", []) or ():
+ if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatingElement", None) == elem_b:
+ _record(rel, "path")
+
+ for rel in getattr(elem_a, "ConnectedFrom", []) or ():
+ if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatingElement", None) == elem_b:
+ kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
+ _record(rel, kind)
+ for rel in getattr(elem_a, "ConnectedTo", []) or ():
+ if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatedElement", None) == elem_b:
+ kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
+ _record(rel, kind)
+
+ return rels
+
+ @classmethod
+ def find_rel(
+ cls,
+ elem_a: "ifcopenshell.entity_instance",
+ elem_b: "ifcopenshell.entity_instance",
+ ) -> "tuple[ifcopenshell.entity_instance | None, str | None]":
+ """Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than
+ ``find_rels`` when callers only need to know whether a connection
+ exists or what kind it is."""
+ rels = cls.find_rels(elem_a, elem_b)
+ return rels[0] if rels else (None, None)
+
+ @classmethod
+ def find_rels_for_element(
+ cls,
+ elem: "ifcopenshell.entity_instance",
+ ) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]":
+ """Return every supported rel touching ``elem`` as ``(rel, kind, partner)``
+ triples. ``partner`` is the *other* element on the rel — the side cascade
+ cleanup must operate on when ``elem`` is being deleted.
+
+ Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry
+ point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object``
+ enumerate everything the disconnect operator would handle pairwise.
+ """
+ result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = []
+ seen: set[int] = set()
+
+ def _record(rel, kind, partner):
+ if partner is None or rel.id() in seen:
+ return
+ seen.add(rel.id())
+ result.append((rel, kind, partner))
+
+ for rel in getattr(elem, "ConnectedTo", []) or ():
+ if rel.is_a("IfcRelConnectsPathElements"):
+ _record(rel, "path", getattr(rel, "RelatedElement", None))
+ elif rel.is_a("IfcRelConnectsElements"):
+ kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
+ _record(rel, kind, getattr(rel, "RelatedElement", None))
+ for rel in getattr(elem, "ConnectedFrom", []) or ():
+ if rel.is_a("IfcRelConnectsPathElements"):
+ _record(rel, "path", getattr(rel, "RelatingElement", None))
+ elif rel.is_a("IfcRelConnectsElements"):
+ kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
+ _record(rel, kind, getattr(rel, "RelatingElement", None))
+
+ return result
+
+ @classmethod
+ def orient_element_top(
+ cls,
+ rel: "ifcopenshell.entity_instance",
+ elem_a: "ifcopenshell.entity_instance",
+ elem_b: "ifcopenshell.entity_instance",
+ ) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]":
+ """Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel.
+
+ The ``extend_walls_to_underside`` flow stores slab as the relating
+ side and wall as related — orientation is recovered by checking
+ which input matches which rel attribute. Callers pass any two
+ elements; this resolves which is the wall and which is the slab so
+ post-disconnect cleanup (regenerate-wall-to-underside) targets the
+ right object."""
+ if getattr(rel, "RelatingElement", None) == elem_a:
+ return elem_b, elem_a
+ return elem_a, elem_b
diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py
index e0b719f065..911c9990fb 100644
--- a/src/bonsai/bonsai/tool/geometry.py
+++ b/src/bonsai/bonsai/tool/geometry.py
@@ -65,6 +65,7 @@ from typing_extensions import TypeIs
import bonsai.bim.helper
import bonsai.bim.import_ifc
+import bonsai.core.connection
import bonsai.core.drawing
import bonsai.core.geometry
import bonsai.core.root
@@ -156,6 +157,112 @@ class Geometry(bonsai.core.tool.Geometry):
for modifier in obj.modifiers:
obj.modifiers.remove(modifier)
+ @classmethod
+ def _group_edges_into_loops(cls, edges) -> list[list]:
+ """Group an edge set into connected components by shared vertices.
+
+ Each returned group is a list of edges that share at least one
+ vertex chain. A hollow profile's bisect produces two disjoint
+ loops (outer ring + inner ring) — grouping splits them so each
+ can be filled independently as a separate cap face, rather than
+ ``contextual_create`` welding them into one solid outer face
+ with the inner loop demoted to interior decoration.
+ """
+ edge_set = set(edges)
+ visited: set = set()
+ groups: list[list] = []
+ for start in edges:
+ if start in visited:
+ continue
+ group: list = []
+ stack: list = [start]
+ while stack:
+ e = stack.pop()
+ if e in visited:
+ continue
+ visited.add(e)
+ group.append(e)
+ for v in e.verts:
+ for adj in v.link_edges:
+ if adj in edge_set and adj not in visited:
+ stack.append(adj)
+ groups.append(group)
+ return groups
+
+ @classmethod
+ def bisect_and_cap(
+ cls,
+ bm,
+ planes_local,
+ *,
+ tag_layer_name: str = "bbim_cap",
+ dist: float = 1e-4,
+ weld_dist: float = 1e-5,
+ ):
+ """Clip ``bm`` against each ``(plane_co, plane_no)`` and fill the cuts.
+
+ Per plane, ``bmesh.ops.bisect_plane(clear_outer=True)`` discards
+ the outside half-space and ``bmesh.ops.contextual_create`` fills
+ the resulting cut edges with cap faces tagged via a BMesh int
+ layer so the tag propagates to any split-children from subsequent
+ planes. After all planes, near-coincident vertices are welded
+ (``weld_dist``) so adjacent caps from the same cross-section
+ merge cleanly.
+
+ Callers are responsible for input mesh quality. Non-watertight
+ inputs (terrain, single-shell surfaces) may produce degenerate
+ cap faces; that's an accepted user-supplied data limitation.
+
+ Returns the cap-tag BMLayerItem, or ``None`` if ``bm`` is empty.
+ """
+ import bmesh
+
+ if not bm.faces:
+ return None
+
+ # Pre-weld nearby verts: T-junctions in messy IFC meshes (a third
+ # vertex sitting in the middle of an edge from a Boolean
+ # operation) make the bisect cut terminate early, leaving open
+ # loops that no fill op can close. Welding the T-junction's
+ # near-coincident vertex into the host edge before bisecting
+ # turns the cut into a closed loop.
+ bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=max(weld_dist, 1e-4))
+
+ cap_layer = bm.faces.layers.int.new(tag_layer_name)
+ for plane_co, plane_no in planes_local:
+ geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
+ if not geom:
+ break
+ results = bmesh.ops.bisect_plane(
+ bm,
+ geom=geom,
+ dist=dist,
+ plane_co=plane_co,
+ plane_no=plane_no,
+ clear_outer=True,
+ )
+ cut_edges = [e for e in results["geom_cut"] if isinstance(e, bmesh.types.BMEdge)]
+ if not cut_edges:
+ continue
+ # Group cut edges into connected components BEFORE filling.
+ # Feeding ``contextual_create`` all edges at once (outer +
+ # inner of a hollow profile) makes it create a SINGLE outer
+ # face and treat inner edges as decoration — collapsing the
+ # hole. Filling each connected loop separately produces one
+ # cap face per ring.
+ for loop_edges in cls._group_edges_into_loops(cut_edges):
+ try:
+ fill = bmesh.ops.contextual_create(bm, geom=loop_edges)
+ except (RuntimeError, TypeError):
+ continue
+ for f in fill.get("faces", []):
+ if isinstance(f, bmesh.types.BMFace) and f.is_valid:
+ f[cap_layer] = 1
+
+ if weld_dist > 0.0:
+ bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=weld_dist)
+ return cap_layer
+
@classmethod
def clear_scale(cls, obj: bpy.types.Object) -> None:
"""Apply and clear object scale.
@@ -271,12 +378,38 @@ class Geometry(bonsai.core.tool.Geometry):
bpy.data.objects.remove(obj)
@classmethod
- def delete_ifc_object(cls, obj: bpy.types.Object) -> None:
+ def delete_ifc_object(
+ cls,
+ obj: bpy.types.Object,
+ batch_being_deleted_ids: Optional[set[int]] = None,
+ ) -> None:
ifc_file = tool.Ifc.get()
element = tool.Ifc.get_entity(obj)
if not element:
return
- elif element.is_a("IfcAnnotation"):
+ # Cascade connection-rel teardown — symmetric to bim.disconnect_elements.
+ # When a slab connected to a wall via IfcRelConnectsElements(TOP) is deleted,
+ # the wall's trim booleans + BBIM_Boolean pset would otherwise be orphaned.
+ # skip_elem_recreate is always True here because we're inside delete: the
+ # element is about to vanish, so re-extruding it would be wasted work.
+ # skip_partner_recreate fires only when the partner is also queued in the
+ # same OverrideDelete batch.
+ if element.is_a("IfcRoot"):
+ skip_ids = batch_being_deleted_ids or set()
+ for rel, kind, partner in tool.Connection.find_rels_for_element(element):
+ bonsai.core.connection.disconnect_rel(
+ tool.Ifc,
+ tool.Geometry,
+ tool.Model,
+ tool.Connection,
+ rel=rel,
+ kind=kind,
+ elem=element,
+ partner=partner,
+ skip_elem_recreate=True,
+ skip_partner_recreate=(partner.id() in skip_ids),
+ )
+ if element.is_a("IfcAnnotation"):
if element.ObjectType == "DRAWING":
return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element)
elif tool.Drawing.is_auto_annotation(element):
@@ -641,7 +774,13 @@ class Geometry(bonsai.core.tool.Geometry):
and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
):
- return tool.Ifc.get().by_id(ifc_id)
+ try:
+ return tool.Ifc.get().by_id(ifc_id)
+ except RuntimeError:
+ # Stale id: a representation rebuild freed the old entity
+ # while obj.data still tracks its id. Treated as "no active
+ # representation" — same contract as a mesh with id 0.
+ return None
@classmethod
def get_data_representation(cls, data: bpy.types.ID) -> ifcopenshell.entity_instance | None:
@@ -2348,6 +2487,21 @@ class Geometry(bonsai.core.tool.Geometry):
old_to_new[element] = [new]
if new.is_a("IfcRelSpaceBoundary"):
tool.Boundary.decorate_boundary(new_obj)
+ # Slab-trim booleans (from extend_walls_to_underside) belong to
+ # the source wall's connection, not the copy. Strip them so the
+ # duplicate reverts to its pre-clip extrusion — mirrors the way
+ # filling rels are dropped while manual booleans persist on copy.
+ # Reload the body when something was stripped so the viewport
+ # immediately shows the unclipped geometry; otherwise the user
+ # sees a stale mesh until they Shift+G, which is easy to miss.
+ if new.is_a("IfcWall"):
+ if tool.Model.strip_underside_booleans(new):
+ tool.Model.reload_body_representation(new_obj)
+ # HasOpenings rels don't follow object duplication, so
+ # the duplicate's body must rebuild to match its current
+ # opening set.
+ else:
+ tool.Model.regenerate_wall(new_obj)
# Remap Blender parent relationships for duplicated objects
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
@@ -2430,7 +2584,10 @@ class Geometry(bonsai.core.tool.Geometry):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
- array_parents.add(tool.Ifc.get().by_guid(pset["Parent"]))
+ try:
+ array_parents.add(tool.Ifc.get().by_guid(pset["Parent"]))
+ except RuntimeError:
+ continue
for array_parent in array_parents:
array_parent_obj = tool.Ifc.get_object(array_parent)
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index 2ea1678479..137bed3371 100644
--- a/src/bonsai/bonsai/tool/model.py
+++ b/src/bonsai/bonsai/tool/model.py
@@ -82,6 +82,7 @@ if TYPE_CHECKING:
BIMPolylineProperties,
BIMRailingProperties,
BIMRoofProperties,
+ BIMSlabProperties,
BIMStairProperties,
BIMSverchokProperties,
BIMWallProperties,
@@ -118,6 +119,10 @@ class Model(bonsai.core.tool.Model):
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
+ @classmethod
+ def get_slab_props(cls, obj: bpy.types.Object) -> BIMSlabProperties:
+ return obj.BIMSlabProperties # pyright: ignore[reportAttributeAccessIssue]
+
@classmethod
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -809,7 +814,7 @@ class Model(bonsai.core.tool.Model):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
layer_params = tool.Model.get_material_layer_parameters(element)
layer_offset = layer_params["offset"]
- thickness = layer_params["thickness"] / unit_scale
+ thickness = layer_params["thickness"]
props = tool.Material.get_object_material_props(obj)
# Try to load from pset if not already in props
@@ -817,7 +822,7 @@ class Model(bonsai.core.tool.Model):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset and pset.get("UseCustomOffset", False):
# Load from pset
- custom_offset = pset.get("CustomOffset", 0.0)
+ custom_offset = pset.get("CustomOffset", 0.0) * unit_scale
usage_type = tool.Model.get_usage_type(element)
if usage_type == "LAYER2":
@@ -830,7 +835,7 @@ class Model(bonsai.core.tool.Model):
return None
else:
# Use current props
- custom_offset = props.custom_offset / unit_scale
+ custom_offset = props.custom_offset
if tool.Model.get_usage_type(element) == "LAYER2":
custom_offset_reference = props.custom_wall_reference
elif tool.Model.get_usage_type(element) == "LAYER3":
@@ -841,17 +846,17 @@ class Model(bonsai.core.tool.Model):
direction_sense = layer_params["direction_sense"]
if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}:
- layer_offset = custom_offset - thickness * unit_scale
+ layer_offset = custom_offset - thickness
if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
- layer_offset = custom_offset - (thickness / 2) * unit_scale
+ layer_offset = custom_offset - (thickness / 2)
if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or (
direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"}
):
layer_offset = custom_offset
if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
- layer_offset = custom_offset + (thickness / 2) * unit_scale
+ layer_offset = custom_offset + (thickness / 2)
if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}:
- layer_offset = custom_offset + thickness * unit_scale
+ layer_offset = custom_offset + thickness
return layer_offset / unit_scale
@@ -904,6 +909,46 @@ class Model(bonsai.core.tool.Model):
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
+ @classmethod
+ def strip_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> bool:
+ """Remove slab-trim ``IfcBooleanResult`` items from a wall's body chain.
+
+ Returns ``True`` if any boolean was removed, so the caller knows whether
+ a Blender-side body reload is needed to surface the geometry change.
+
+ Hook for the duplicate path (Shift+D): the source wall's clip booleans
+ don't make sense on a copy pulled away from the slab. Booleans whose
+ ``SecondOperand.is_a("IfcTessellatedFaceSet")`` are removed — same
+ imprecise discriminator the rest of the wall-to-underside machinery
+ uses (manual cuts authored from tessellated meshes would also be
+ stripped, but most manual cuts use ``IfcExtrudedAreaSolid`` / CSG
+ primitives and are unaffected).
+
+ Cannot reuse ``remove_wall_to_underside_booleans`` here because the
+ duplicate's ``BBIM_Boolean.Data`` holds the source wall's stale ids —
+ ``get_manual_booleans`` returns empty on the copy and the helper
+ early-returns. The duplicate hook works directly off the chain.
+ """
+ representation = tool.Geometry.get_body_representation(wall)
+ if not representation:
+ return False
+ chain = cls.get_booleans(wall, representation)
+ to_remove = [b for b in chain if (sec := b.SecondOperand) is not None and sec.is_a("IfcTessellatedFaceSet")]
+ for b in to_remove:
+ tool.Geometry.remove_representation_item(b.SecondOperand, wall)
+ # Sweep the now-stale BBIM_Boolean entries on the copy (their ids point
+ # at booleans that were never in this wall's chain — they survived the
+ # ifcopenshell deep copy as JSON text in the pset payload).
+ pset_data = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
+ if pset_data:
+ representation = tool.Geometry.get_body_representation(wall)
+ chain_ids = {b.id() for b in cls.get_booleans(wall, representation)} if representation else set()
+ stored_ids = set(json.loads(pset_data["Data"]))
+ stale_ids = stored_ids - chain_ids
+ if stale_ids:
+ cls.unmark_manual_booleans(wall, list(stale_ids))
+ return bool(to_remove)
+
@classmethod
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
@@ -1275,7 +1320,10 @@ class Model(bonsai.core.tool.Model):
# handle elements unused in the array after regeneration
removed_children = set(existing_children) - set(array["children"])
for removed_child in removed_children:
- element = tool.Ifc.get().by_guid(removed_child)
+ try:
+ element = tool.Ifc.get().by_guid(removed_child)
+ except RuntimeError:
+ continue
# Strip any wall/slab opening cut by this child before deletion,
# so the host's HasOpenings shrinks symmetrically with count.
if getattr(element, "FillsVoids", None):
@@ -3024,6 +3072,19 @@ class Model(bonsai.core.tool.Model):
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix)
tool.Geometry.record_object_position(obj)
+ @classmethod
+ def regenerate_wall(cls, obj: bpy.types.Object) -> None:
+ """Rebuild a wall's body from current IFC state: extrusion + openings
+ first, then re-clip to any surviving ``IfcRelConnectsElements(TOP)``
+ slab. Safe on walls with no openings and no slab connection — both
+ steps no-op against their preconditions."""
+ element = tool.Ifc.get_entity(obj)
+ if element is None:
+ return
+ cls.recreate_wall(element, obj)
+ if cls.has_underside_connection(element):
+ bonsai.core.model.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, cls, [obj])
+
@classmethod
def recalculate_walls(cls, walls: list[bpy.types.Object]) -> None:
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py
index 3c3fba66f5..313133f192 100644
--- a/src/bonsai/bonsai/tool/parametric.py
+++ b/src/bonsai/bonsai/tool/parametric.py
@@ -157,6 +157,7 @@ class Parametric(bonsai.core.tool.Parametric):
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
+ ParametricObject("slab"),
]
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
@@ -171,6 +172,7 @@ class Parametric(bonsai.core.tool.Parametric):
PIPE_SEGMENT: ClassVar[ParametricObject]
DUCT_SEGMENT: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
+ SLAB: ClassVar[ParametricObject]
_geom_generation: int = 0
@@ -459,6 +461,15 @@ class Parametric(bonsai.core.tool.Parametric):
return False
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None
+ @classmethod
+ def is_slab(cls, element: entity_instance) -> bool:
+ """``True`` for any ``IfcSlab``. The slab edit lifecycle only gates
+ the connection-disconnect UI — no IFC mutation — so we don't narrow
+ further (e.g. by checking for wall connections). Per-gizmo polls
+ layer the "has wall connections" check on top via
+ ``tool.Wall.iter_slab_wall_connections``."""
+ return element is not None and element.is_a("IfcSlab")
+
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py
index c982b15371..b3c850e790 100644
--- a/src/bonsai/bonsai/tool/wall.py
+++ b/src/bonsai/bonsai/tool/wall.py
@@ -242,6 +242,75 @@ class Wall(bonsai.core.tool.Wall):
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
+ @classmethod
+ def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
+ """Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
+ connecting a slab to this wall — the rel kind ``extend_walls_to_underside``
+ creates. Walks ``wall.ConnectedFrom`` because the slab is the relating
+ side of the TOP rel."""
+ for rel in getattr(wall, "ConnectedFrom", []) or ():
+ if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
+ continue
+ slab = rel.RelatingElement
+ if slab is None:
+ continue
+ yield slab, rel
+
+ @classmethod
+ def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
+ """Yield ``(wall, rel)`` tuples for every wall clipped to this slab's
+ underside. Mirror of ``iter_wall_slab_connections`` from the slab side
+ — walks ``slab.ConnectedTo``."""
+ for rel in getattr(slab, "ConnectedTo", []) or ():
+ if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
+ continue
+ wall = rel.RelatedElement
+ if wall is None:
+ continue
+ yield wall, rel
+
+ @classmethod
+ def find_wall_slab_rel(
+ cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance
+ ) -> ifcopenshell.entity_instance | None:
+ """Return the single ``IfcRelConnectsElements(TOP)`` between ``wall``
+ and ``slab``, or ``None`` if none exists. Used by the disconnect
+ operator to find the specific rel to remove."""
+ for s, rel in cls.iter_wall_slab_connections(wall):
+ if s == slab:
+ return rel
+ return None
+
+ WALL_SLAB_CONNECTION_Z_CLEARANCE = 0.5
+ """Lift above the wall top so the disconnect icon sits above the
+ extend-vertical / slope gizmo and reads as "the thing above the wall =
+ the slab connection"."""
+
+ @classmethod
+ def wall_slab_connection_location_world(
+ cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object
+ ) -> Vector | None:
+ """World-space anchor for the wall-slab disconnect icon.
+
+ X / Y come from the wall axis midpoint (so the icon sits in the
+ middle of the wall horizontally); Z is the wall's top in world space
+ plus ``WALL_SLAB_CONNECTION_Z_CLEARANCE`` so the icon perches above
+ the slope gizmo. The slab-side gizmo calls this with the same
+ arguments so both sides of the same connection render a single
+ visual marker. ``slab_obj`` is kept on the signature for the
+ symmetric call shape; the helper's body no longer reads from it.
+ Returns ``None`` when the wall has no reference line."""
+ ref = cls.get_world_reference_line(wall_obj)
+ if ref is None:
+ return None
+ axis_mid_world = (ref[0] + ref[1]) * 0.5
+ if wall_obj.bound_box:
+ wall_top_local_z = max(c[2] for c in wall_obj.bound_box)
+ wall_top_world_z = (wall_obj.matrix_world @ Vector((0.0, 0.0, wall_top_local_z))).z
+ else:
+ wall_top_world_z = axis_mid_world.z
+ return Vector((axis_mid_world.x, axis_mid_world.y, wall_top_world_z + cls.WALL_SLAB_CONNECTION_Z_CLEARANCE))
+
@classmethod
def walk_connected_walls(
cls,
diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini
index e628606201..f4dbb884b6 100644
--- a/src/bonsai/pytest.ini
+++ b/src/bonsai/pytest.ini
@@ -8,6 +8,7 @@ markers =
brick
bsdd
classification
+ clip_box
context
cost
covering
diff --git a/src/bonsai/test/bim/conftest.py b/src/bonsai/test/bim/conftest.py
index 2d69fe415a..a957b479ba 100644
--- a/src/bonsai/test/bim/conftest.py
+++ b/src/bonsai/test/bim/conftest.py
@@ -1,5 +1,46 @@
import pytest
+
+class _FakePropsBase:
+ """Base for parametric-edit PropertyGroup stand-ins used in lifecycle tests.
+
+ The parametric-edit lifecycle mixins read/write a common contract:
+ ``is_editing`` (bool), ``last_kwargs`` (dict | None — capture of the last
+ data written via ``set_props_kwargs_from_ifc_data``),
+ ``set_props_kwargs_from_ifc_data(data)``, and
+ ``get_general_kwargs(convert_to_project_units=True)``. Per-type stand-ins
+ (door, railing, roof) subclass this and add their own kwargs accessors
+ and per-type fields."""
+
+ def __init__(self, general: dict | None = None):
+ self.is_editing = False
+ self.last_kwargs: dict | None = None
+ self.general = dict(general) if general is not None else {}
+
+ def set_props_kwargs_from_ifc_data(self, data):
+ self.last_kwargs = dict(data)
+
+ def get_general_kwargs(self, convert_to_project_units=True):
+ return dict(self.general)
+
+
+def make_lifecycle_obj(props, *, name="obj"):
+ """Build a ``bpy.types.Object`` stand-in for parametric-lifecycle tests.
+
+ The mixin code under test reads ``obj.props`` (the PropertyGroup
+ stand-in) and ``obj.name`` (used in error reports). ``spec=bpy.types.Object``
+ catches typo'd attribute access at test time. ``bpy`` is imported inside
+ the function so this conftest stays importable when bpy is absent."""
+ from unittest import mock
+
+ import bpy
+
+ obj = mock.Mock(spec=bpy.types.Object, name=name)
+ obj.props = props
+ obj.name = name
+ return obj
+
+
# pytest by default doesn't print steps and where it failed. Let's fix that.
diff --git a/src/bonsai/test/bim/module/clip_box/__init__.py b/src/bonsai/test/bim/module/clip_box/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_box.py b/src/bonsai/test/bim/module/clip_box/test_clip_box.py
new file mode 100644
index 0000000000..92a51e6390
--- /dev/null
+++ b/src/bonsai/test/bim/module/clip_box/test_clip_box.py
@@ -0,0 +1,692 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+import math
+from unittest.mock import patch
+
+import bpy
+import pytest
+from mathutils import Matrix, Vector
+
+import bonsai.tool as tool
+from test.bim.bootstrap import NewFile
+
+pytestmark = pytest.mark.clip_box
+
+
+class TestAddClipBox(NewFile):
+ def test_creates_empty_and_registers_entry(self):
+ result = bpy.ops.bim.add_clip_box()
+ assert result == {"FINISHED"}
+ scene_props = tool.ClipBox.get_scene_props()
+ assert len(scene_props.clip_boxes) == 1
+ host = scene_props.clip_boxes[0].obj
+ assert host is not None
+ assert host.empty_display_type == "CUBE"
+ obj_props = tool.ClipBox.get_object_props(host)
+ assert obj_props.is_clip_box is True
+
+ def test_spawns_at_3d_cursor(self):
+ bpy.context.scene.cursor.location = (4.0, 0.0, 2.0)
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ assert host is not None
+ assert host.matrix_world.translation.x == pytest.approx(4.0)
+ assert host.matrix_world.translation.z == pytest.approx(2.0)
+
+ def test_spawns_in_clip_boxes_collection(self):
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ collection_names = [c.name for c in host.users_collection]
+ assert "BBIM_ClipBoxes" in collection_names
+
+
+class TestActiveClipBoxResolution(NewFile):
+ def test_no_box_returns_none(self):
+ assert tool.ClipBox.get_active_clip_box() is None
+
+ def test_active_index_out_of_range_returns_none(self):
+ bpy.ops.bim.add_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.active_clip_box_index = 99
+ assert tool.ClipBox.get_active_clip_box() is None
+
+
+class TestComputePlanes(NewFile):
+ def test_planes_match_unit_box_at_origin(self):
+ bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Identity(4)
+
+ planes = tool.ClipBox.compute_planes(host)
+ assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, 0)))
+ assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2, 0, 0)))
+ assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, -2)))
+
+ def test_scaled_host_grows_clip_region(self):
+ bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
+
+ # Test points well clear of any reasonable expand margin so the
+ # assertion pins the OBB scaling behaviour, not the margin value.
+ planes = tool.ClipBox.compute_planes(host)
+ assert tool.Cad.point_is_inside_clip_planes(planes, Vector((2.5, 0, 0)))
+ assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((4.0, 0, 0)))
+
+ def test_rotated_host_rotates_clip_region(self):
+ bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z")
+
+ # Test points well clear of the expand margin so the assertion
+ # pins rotation, not the margin value.
+ planes = tool.ClipBox.compute_planes(host)
+ assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0.5, 0, 0)))
+ assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2.0, 0, 0)))
+
+ def test_translated_and_rotated_host_keeps_centre_inside(self):
+ bpy.context.scene.cursor.location = (5.0, 7.0, 0.0)
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Translation((5.0, 7.0, 0.0)) @ Matrix.Rotation(math.radians(30), 4, "Z")
+
+ planes = tool.ClipBox.compute_planes(host)
+ assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0)))
+
+
+class TestToggleEnabled(NewFile):
+ def test_flips_scene_enabled_flag(self):
+ bpy.ops.bim.add_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ original = scene_props.enabled
+ bpy.ops.bim.toggle_clip_box_enabled()
+ assert scene_props.enabled is (not original)
+
+
+class TestSetActiveClipBox(NewFile):
+ def test_switches_active_index(self):
+ bpy.ops.bim.add_clip_box()
+ bpy.ops.bim.add_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ assert scene_props.active_clip_box_index == 1
+ bpy.ops.bim.set_active_clip_box(index=0)
+ assert scene_props.active_clip_box_index == 0
+
+ def test_invalid_index_cancels(self):
+ bpy.ops.bim.add_clip_box()
+ result = bpy.ops.bim.set_active_clip_box(index=99)
+ assert result == {"CANCELLED"}
+
+
+class TestRemoveClipBox(NewFile):
+ def test_drops_active_entry_when_no_index(self):
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host_name = host.name
+ bpy.ops.bim.remove_clip_box(delete_object=True)
+ assert host_name not in bpy.data.objects
+ scene_props = tool.ClipBox.get_scene_props()
+ assert len(scene_props.clip_boxes) == 0
+
+ def test_drops_specified_index(self):
+ # Per-row UIList button passes index explicitly; the user can
+ # click X on any row without first selecting it as active.
+ bpy.ops.bim.add_clip_box()
+ bpy.ops.bim.add_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ first_name = scene_props.clip_boxes[0].obj.name
+ bpy.ops.bim.remove_clip_box(index=0)
+ assert first_name not in bpy.data.objects
+ assert len(scene_props.clip_boxes) == 1
+
+ def test_no_active_box_cancels(self):
+ result = bpy.ops.bim.remove_clip_box()
+ assert result == {"CANCELLED"}
+
+ def test_out_of_range_index_cancels(self):
+ bpy.ops.bim.add_clip_box()
+ result = bpy.ops.bim.remove_clip_box(index=99)
+ assert result == {"CANCELLED"}
+
+
+class TestPsetPersistence(NewFile):
+ def test_add_clip_box_writes_project_pset(self):
+ import ifcopenshell.util.element
+
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+ project = tool.Ifc.get().by_type("IfcProject")[0]
+ psets = ifcopenshell.util.element.get_psets(project)
+ assert tool.ClipBox.PSET_NAME in psets
+ assert psets[tool.ClipBox.PSET_NAME]["Count"] == 1
+
+ def test_round_trip_via_pset_restores_matrix(self):
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0))
+ tool.ClipBox.save_to_project_pset()
+
+ # Simulate a fresh-load state: clear scene list AND delete the
+ # Blender empty so load has to recreate it.
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.clip_boxes.clear()
+ bpy.data.objects.remove(host, do_unlink=True)
+
+ tool.ClipBox.load_from_project_pset()
+ assert len(scene_props.clip_boxes) == 1
+ rehydrated = scene_props.clip_boxes[0].obj
+ assert rehydrated is not None
+ for r in range(4):
+ for c in range(4):
+ expected = (Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)))[r][c]
+ assert rehydrated.matrix_world[r][c] == pytest.approx(expected, abs=1e-6)
+
+ def test_load_from_pset_is_idempotent(self):
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+ tool.ClipBox.load_from_project_pset()
+ tool.ClipBox.load_from_project_pset()
+ assert len(tool.ClipBox.get_scene_props().clip_boxes) == 1
+
+ def test_load_from_pset_does_not_touch_enabled(self):
+ # ``enabled`` is intentionally not persisted to the pset — the
+ # default is False (fresh .blend) and Blender's own .blend
+ # session save carries the user's saved value through reload.
+ # ``load_from_project_pset`` must not stomp either.
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.enabled = True
+ tool.ClipBox.save_to_project_pset()
+
+ # Simulate the depsgraph IFC-reload branch: load runs without
+ # touching enabled; the prior True value must survive.
+ tool.ClipBox.load_from_project_pset()
+ assert scene_props.enabled is True
+
+ # And the opposite: load when False must not flip it True.
+ scene_props.enabled = False
+ tool.ClipBox.load_from_project_pset()
+ assert scene_props.enabled is False
+
+ def test_add_clip_box_creates_no_ifc_entity(self):
+ # The clip box is project-pset persisted; there must be no
+ # IfcRoot entity attached to the empty (which would lock its
+ # scale and strip it on export).
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ assert tool.Ifc.get_entity(host) is None
+
+ def test_show_caps_round_trips_via_pset(self):
+ # show_caps is a scene-level toggle persisted in the project pset.
+ # Per-mesh cap cost dominates the bisect, which doesn't scale with
+ # clip-box extent, so the toggle applies file-wide.
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.show_caps = False
+ tool.ClipBox.save_to_project_pset()
+
+ scene_props.clip_boxes.clear()
+ scene_props.show_caps = True # default; load_from_project_pset must flip back to False
+ bpy.data.objects.remove(host, do_unlink=True)
+
+ tool.ClipBox.load_from_project_pset()
+ assert scene_props.show_caps is False
+
+
+class TestCapGeneration(NewFile):
+ def test_cap_for_box_straddling_clip_plane_produces_triangles(self):
+ # A 2x2x2 cube centred at the origin, clipped by a unit-radius clip
+ # box also at the origin: the four faces of the cube that pierce
+ # the +/- x box faces should yield cap polygons on the two faces.
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Identity(4) # unit box at origin
+
+ bpy.ops.mesh.primitive_cube_add(size=4.0, location=(0.0, 0.0, 0.0))
+ cube = bpy.context.active_object
+
+ world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
+ verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
+
+ # Every cap is at least one triangle (3 verts each), and we expect
+ # 6 cap polygons (one per box face) → at minimum 18 verts.
+ assert len(verts) >= 18
+ assert len(verts) % 3 == 0
+
+ def test_cap_for_mesh_entirely_outside_box_produces_nothing(self):
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Identity(4)
+
+ bpy.ops.mesh.primitive_cube_add(size=1.0, location=(10.0, 0.0, 0.0))
+ cube = bpy.context.active_object
+
+ world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
+ verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
+ assert verts == []
+
+ def test_cap_for_mesh_entirely_inside_box_produces_nothing(self):
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Identity(4)
+
+ bpy.ops.mesh.primitive_cube_add(size=0.5, location=(0.0, 0.0, 0.0))
+ cube = bpy.context.active_object
+
+ world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
+ verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
+ assert verts == []
+
+ def test_non_watertight_mesh_does_not_crash(self):
+ # The cap pipeline assumes watertight input; non-watertight
+ # meshes (terrain, single-shell surfaces) may produce degenerate
+ # caps but must not raise. The user is responsible for input
+ # quality.
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ host.matrix_world = Matrix.Identity(4)
+
+ bpy.ops.mesh.primitive_grid_add(size=4.0, location=(0.0, 0.0, 0.0))
+ grid = bpy.context.active_object
+
+ world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
+ verts = tool.ClipBox._compute_caps_for_object(grid, world_planes)
+ assert isinstance(verts, list)
+
+ def test_show_caps_defaults_on_and_toggles(self):
+ bpy.ops.bim.add_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ assert scene_props.show_caps is True
+ scene_props.show_caps = False
+ assert scene_props.show_caps is False
+
+
+class TestCapEligibility(NewFile):
+ def test_ifc_space_is_not_capped(self):
+ # IfcSpace is an IfcProduct (spatial structure) but should never
+ # cap — spaces are non-physical containers; capping them sprouts
+ # solid fills where the room boundary crosses the clip plane.
+ tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+
+ bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
+ space_obj = bpy.context.active_object
+ tool.Root.get_root_props().ifc_product = "IfcSpatialElement"
+ bpy.ops.bim.assign_class(ifc_class="IfcSpace")
+
+ capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
+ assert space_obj not in capable
+
+ def test_ifc_wall_is_capped(self):
+ tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+
+ bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
+ wall_obj = bpy.context.active_object
+ tool.Root.get_root_props().ifc_product = "IfcElement"
+ bpy.ops.bim.assign_class(ifc_class="IfcWall")
+
+ capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
+ assert wall_obj in capable
+
+ def test_pure_blender_mesh_is_not_capped(self):
+ tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
+ bpy.ops.bim.create_project()
+ bpy.ops.bim.add_clip_box()
+
+ bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
+ cube = bpy.context.active_object
+ # No assign_class — pure Blender mesh, no IFC entity attached.
+ capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
+ assert cube not in capable
+
+
+class TestDuplicateClipBox(NewFile):
+ def test_duplicates_active_when_no_index(self):
+ bpy.ops.bim.add_clip_box()
+ source = tool.ClipBox.get_active_clip_box()
+ source.matrix_world = Matrix.Translation((3.0, 4.0, 5.0)) @ Matrix.Diagonal((2.0, 1.0, 1.0, 1.0))
+
+ bpy.ops.bim.duplicate_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ assert len(scene_props.clip_boxes) == 2
+ copy = tool.ClipBox.get_active_clip_box()
+ assert copy is not source
+ for r in range(4):
+ for c in range(4):
+ assert copy.matrix_world[r][c] == pytest.approx(source.matrix_world[r][c])
+ assert tool.ClipBox.get_object_props(copy).is_clip_box is True
+
+ def test_duplicates_specified_index(self):
+ bpy.ops.bim.add_clip_box()
+ first = tool.ClipBox.get_active_clip_box()
+ bpy.ops.bim.add_clip_box()
+ # Active is now index 1; duplicate index 0 explicitly.
+ bpy.ops.bim.duplicate_clip_box(index=0)
+ scene_props = tool.ClipBox.get_scene_props()
+ assert len(scene_props.clip_boxes) == 3
+ copy = tool.ClipBox.get_active_clip_box()
+ for r in range(4):
+ for c in range(4):
+ assert copy.matrix_world[r][c] == pytest.approx(first.matrix_world[r][c])
+
+ def test_no_active_box_cancels(self):
+ result = bpy.ops.bim.duplicate_clip_box()
+ assert result == {"CANCELLED"}
+
+ def test_out_of_range_index_cancels(self):
+ bpy.ops.bim.add_clip_box()
+ result = bpy.ops.bim.duplicate_clip_box(index=99)
+ assert result == {"CANCELLED"}
+
+ def test_duplicate_arms_clipping(self):
+ bpy.ops.bim.add_clip_box() # arms
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.enabled = False # user disables
+ bpy.ops.bim.duplicate_clip_box() # re-arms
+ assert scene_props.enabled is True
+
+
+class TestCollectionSync(NewFile):
+ def test_sync_adopts_orphan_clip_box_empty(self):
+ # Simulates Bonsai's Shift+D duplicate: an empty with is_clip_box=True
+ # exists in the BBIM_ClipBoxes collection but no scene-list entry
+ # points at it. The sync must adopt it as a first-class clip box.
+ bpy.ops.bim.add_clip_box()
+ source = tool.ClipBox.get_active_clip_box()
+
+ orphan = bpy.data.objects.new(source.name, None)
+ orphan.empty_display_type = "CUBE"
+ orphan.empty_display_size = 1.0
+ orphan.matrix_world = source.matrix_world.copy()
+ tool.ClipBox.get_object_props(orphan).is_clip_box = True
+ collection = bpy.data.collections.get("BBIM_ClipBoxes")
+ collection.objects.link(orphan)
+
+ tool.ClipBox._sync_collection_to_list(bpy.context.scene)
+ scene_props = tool.ClipBox.get_scene_props()
+ assert len(scene_props.clip_boxes) == 2
+ assert scene_props.clip_boxes[-1].obj is orphan
+ assert scene_props.active_clip_box_index == 1
+
+ def test_sync_skips_unflagged_empties(self):
+ bpy.ops.bim.add_clip_box()
+ collection = bpy.data.collections.get("BBIM_ClipBoxes")
+ decoy = bpy.data.objects.new("Decoy", None)
+ collection.objects.link(decoy)
+
+ tool.ClipBox._sync_collection_to_list(bpy.context.scene)
+ scene_props = tool.ClipBox.get_scene_props()
+ assert len(scene_props.clip_boxes) == 1
+
+
+class TestEnabledIsSceneLevel(NewFile):
+ def test_default_is_false(self):
+ scene_props = tool.ClipBox.get_scene_props()
+ assert scene_props.enabled is False
+
+ def test_add_arms_clipping(self):
+ # Adding any clip box flips enabled True so the user
+ # immediately sees the cut and discovers the panel toggle
+ # by association.
+ scene_props = tool.ClipBox.get_scene_props()
+ assert scene_props.enabled is False
+ bpy.ops.bim.add_clip_box()
+ assert scene_props.enabled is True
+
+ def test_subsequent_adds_re_arm_after_user_disables(self):
+ bpy.ops.bim.add_clip_box() # arms
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.enabled = False # user disables
+ bpy.ops.bim.add_clip_box() # second add re-arms
+ assert scene_props.enabled is True
+
+ def test_selecting_clip_box_does_not_arm(self):
+ # Per design, selecting a clip box empty must NOT toggle the
+ # scene-level enabled — activation is panel-only. Otherwise a
+ # casual click in the outliner would silently hide geometry
+ # with no obvious unarm path for a user who hasn't found the
+ # panel yet.
+ bpy.ops.bim.add_clip_box()
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.enabled = False # disable after first-add auto-arm
+
+ host = tool.ClipBox.get_active_clip_box()
+ bpy.context.view_layer.objects.active = host
+ tool.ClipBox.on_depsgraph_update(bpy.context.scene, None)
+ assert scene_props.enabled is False
+
+ def test_toggle_operator_flips_scene_enabled(self):
+ bpy.ops.bim.add_clip_box() # first-add arms it
+ scene_props = tool.ClipBox.get_scene_props()
+ assert scene_props.enabled is True
+ bpy.ops.bim.toggle_clip_box_enabled()
+ assert scene_props.enabled is False
+ bpy.ops.bim.toggle_clip_box_enabled()
+ assert scene_props.enabled is True
+
+
+class TestSpawnScale(NewFile):
+ def test_default_scale_is_ten(self):
+ # Default spawn scale is 10 (=20m cube) to cover a typical
+ # storey, not the meaningless 1m unit cube.
+ bpy.ops.bim.add_clip_box()
+ host = tool.ClipBox.get_active_clip_box()
+ assert tuple(host.scale) == pytest.approx((10.0, 10.0, 10.0))
+
+
+class TestCapRebuildDebounce(NewFile):
+ """The depsgraph handler debounces cap rebuilds so a burst of
+ updates (e.g. an external-addon gizmo drag) collapses to one
+ rebuild ~250 ms after the storm subsides. Bonsai's own transform
+ modals get a fast path: an immediate rebuild on the True→False
+ transition of ``is_transform_modal_active``.
+ """
+
+ def setup_method(self):
+ bpy.ops.bim.add_clip_box()
+ tool.ClipBox._cancel_pending_cap_rebuild()
+ tool.ClipBox._last_modal_state = False
+ tool.ClipBox._last_seen_object_matrices.clear()
+
+ def teardown_method(self):
+ tool.ClipBox._cancel_pending_cap_rebuild()
+ tool.ClipBox._last_modal_state = False
+ tool.ClipBox._last_seen_object_matrices.clear()
+
+ def test_modal_end_triggers_immediate_rebuild(self):
+ # Prime "previous tick had a modal active" then run a tick with
+ # no modal → fast path fires rebuild_cap_cache synchronously,
+ # bypassing the timer. Targets _handle_cap_tick directly to
+ # bypass the screen guard that aborts in headless test runs.
+ tool.ClipBox._last_modal_state = True
+ with (
+ patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
+ patch.object(tool.ClipBox, "rebuild_cap_cache") as mock_rebuild,
+ patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
+ ):
+ tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
+ mock_rebuild.assert_called_once()
+ mock_schedule.assert_not_called()
+
+ def test_burst_collapses_to_one_pending_timer(self):
+ # 5 ticks with no modal active → schedule called 5 times; each
+ # call cancels the previous pending timer and registers a fresh
+ # one, so exactly one timer is pending at the end.
+ with (
+ patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
+ patch.object(tool.ClipBox, "rebuild_cap_cache"),
+ ):
+ for _ in range(5):
+ tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
+ pending = tool.ClipBox._pending_cap_rebuild
+ assert pending is not None
+ assert bpy.app.timers.is_registered(pending)
+ tool.ClipBox._cancel_pending_cap_rebuild()
+
+ def test_pending_rebuild_hides_caps(self):
+ # While a debounce is in flight, on_post_view_caps must not
+ # draw — the cached batches reflect an earlier frame and would
+ # look stale against the geometry being mutated.
+ scene_props = tool.ClipBox.get_scene_props()
+ scene_props.enabled = True
+ scene_props.show_caps = True
+
+ with (
+ patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
+ patch.object(tool.ClipBox, "rebuild_cap_cache"),
+ ):
+ tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
+ assert tool.ClipBox._pending_cap_rebuild is not None
+
+ # Populate cap_cache to non-empty so the first-line gate
+ # "if not cls._cap_cache: return" doesn't fire — the contract
+ # we're pinning is the pending-rebuild gate specifically.
+ tool.ClipBox._cap_cache["sentinel"] = ((), None)
+ try:
+ # If pending-rebuild gate works, on_post_view_caps exits
+ # before importing gpu / building a shader. Patch
+ # gpu.shader.from_builtin to fail loudly if drawing happens.
+ with (
+ patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
+ patch("gpu.shader.from_builtin", side_effect=AssertionError("should be hidden")),
+ ):
+ tool.ClipBox.on_post_view_caps()
+ finally:
+ tool.ClipBox._cap_cache.pop("sentinel", None)
+ tool.ClipBox._cancel_pending_cap_rebuild()
+
+ def test_cancel_pending_drops_timer(self):
+ with patch.object(tool.Blender, "is_transform_modal_active", return_value=False):
+ tool.ClipBox._schedule_cap_rebuild()
+ pending = tool.ClipBox._pending_cap_rebuild
+ assert pending is not None and bpy.app.timers.is_registered(pending)
+ tool.ClipBox._cancel_pending_cap_rebuild()
+ assert tool.ClipBox._pending_cap_rebuild is None
+ assert not bpy.app.timers.is_registered(pending)
+
+ def test_unregister_handler_cancels_pending(self):
+ # The module's unregister() must drop any pending rebuild so a
+ # timer can't fire against a freed addon. Exercise the helper
+ # directly — full addon unregister would tear down too much for
+ # a unit test.
+ with patch.object(tool.Blender, "is_transform_modal_active", return_value=False):
+ tool.ClipBox._schedule_cap_rebuild()
+ assert tool.ClipBox._pending_cap_rebuild is not None
+ tool.ClipBox._cancel_pending_cap_rebuild()
+ assert tool.ClipBox._pending_cap_rebuild is None
+
+ def test_selection_only_tick_does_not_schedule(self):
+ # Selecting an Object raises is_updated_transform=True on the
+ # Object itself even though no actual matrix delta occurred
+ # (Blender quirk). The matrix-hash baseline must filter that
+ # out so the cache and hide-while-pending gate don't flash on
+ # every click. Also covers the Scene/ViewLayer noise.
+ bpy.ops.mesh.primitive_cube_add()
+ cube = bpy.context.active_object
+
+ # Prime the baseline so cube's current matrix hash is "seen".
+ tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world)
+
+ class _SceneUpdate:
+ id = bpy.context.scene
+ is_updated_geometry = False
+ is_updated_transform = True
+
+ class _CubeSelectionUpdate:
+ id = cube
+ is_updated_geometry = False
+ is_updated_transform = True # quirk: matrix unchanged
+
+ class _FakeDepsgraph:
+ updates = (_SceneUpdate(), _CubeSelectionUpdate())
+
+ with (
+ patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
+ patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
+ ):
+ tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
+ mock_schedule.assert_not_called()
+ assert tool.ClipBox._pending_cap_rebuild is None
+
+ def test_real_transform_tick_does_schedule(self):
+ bpy.ops.mesh.primitive_cube_add()
+ cube = bpy.context.active_object
+ # Baseline hash, then mutate the matrix so the filter sees a
+ # true delta on the next tick.
+ tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world)
+ cube.matrix_world = cube.matrix_world @ Matrix.Translation((1.0, 0, 0))
+
+ class _TransformUpdate:
+ id = cube
+ is_updated_geometry = False
+ is_updated_transform = True
+
+ class _FakeDepsgraph:
+ updates = (_TransformUpdate(),)
+
+ with (
+ patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
+ patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
+ ):
+ tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
+ mock_schedule.assert_called_once()
+
+ def test_geometry_update_tick_does_schedule(self):
+ bpy.ops.mesh.primitive_cube_add()
+ cube = bpy.context.active_object
+
+ class _GeometryUpdate:
+ id = cube
+ is_updated_geometry = True
+ is_updated_transform = False
+
+ class _FakeDepsgraph:
+ updates = (_GeometryUpdate(),)
+
+ with (
+ patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
+ patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
+ ):
+ tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
+ mock_schedule.assert_called_once()
+
+ def test_edit_mode_skips_scheduling(self):
+ # In any EDIT_* mode the depsgraph fires per vert/edge nudge;
+ # the cap view isn't the focus and would flash off on every
+ # tick. The entry-point gate must short-circuit before the
+ # debounce scheduler runs.
+ with (
+ patch.object(tool.Blender, "is_in_edit_mode", return_value=True),
+ patch.object(tool.ClipBox, "_handle_cap_tick") as mock_handle,
+ ):
+ tool.ClipBox.on_depsgraph_update_caps(bpy.context.scene, None)
+ mock_handle.assert_not_called()
diff --git a/src/bonsai/test/bim/module/drawing/test_gizmos.py b/src/bonsai/test/bim/module/drawing/test_gizmos.py
index cc781cd118..18f640549b 100644
--- a/src/bonsai/test/bim/module/drawing/test_gizmos.py
+++ b/src/bonsai/test/bim/module/drawing/test_gizmos.py
@@ -24,7 +24,11 @@ from types import SimpleNamespace
import bpy
import pytest
-from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
+from bonsai.bim.module.drawing.gizmos import (
+ BaseParametricGizmoGroup,
+ BaseSchematicGizmoGroup,
+ DimensionGizmoConfig,
+)
pytestmark = pytest.mark.drawing
@@ -52,3 +56,19 @@ def test_text_formatter_receives_props_and_value():
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"
+
+
+def test_parametric_base_enables_dimension_snap_by_default():
+ """In-place parametric gizmos align to real-world geometry, so dragging
+ must respect the global snap toggle (Ctrl-flip during drag) — same
+ contract every door / window / wall / stair / roof / mep dimension
+ has shipped with."""
+ assert BaseParametricGizmoGroup.snap_enabled_on_dimensions is True
+
+
+def test_schematic_base_disables_dimension_snap():
+ """Schematic dimensions float in viewport space; snapping the dragged
+ tip to scene vertices would produce spurious value jumps as the
+ mouse crosses unrelated geometry. The opt-out lives on the base so
+ every schematic subclass inherits it without per-class wiring."""
+ assert BaseSchematicGizmoGroup.snap_enabled_on_dimensions is False
diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py
new file mode 100644
index 0000000000..da668e604a
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py
@@ -0,0 +1,457 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Behaviour tests for the unified ``bim.disconnect_elements`` operator and
+``tool.Connection.find_rels`` registry.
+
+Pin the dispatch contract: rels are found in either orientation; the kind
+label drives cleanup (``path`` recreates both walls + resyncs drafts;
+``element-top`` runs ``regenerate_wall_to_underside``); missing endpoints
+report ERROR rather than crashing."""
+
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+
+import bonsai.tool as tool
+
+pytestmark = pytest.mark.model
+
+
+def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: int = 0):
+ rel = Mock()
+ rel.is_a = lambda c: c == klass
+ rel.RelatingElement = relating
+ rel.RelatedElement = related
+ rel.Description = description
+ rel.id = lambda: rel_id
+ return rel
+
+
+def _elem(*, connected_to=(), connected_from=()):
+ e = Mock()
+ e.ConnectedTo = list(connected_to)
+ e.ConnectedFrom = list(connected_from)
+ e.GlobalId = "GUID"
+ return e
+
+
+# ---------------------------------------------------------------------------
+# tool.Connection.find_rels — registry behaviour
+# ---------------------------------------------------------------------------
+
+
+def test_find_rels_returns_path_rel_in_either_orientation():
+ """The same wall pair can carry path rels authored with either orientation;
+ find_rels must catch both."""
+ elem_a = _elem()
+ elem_b = _elem()
+ rel_ab = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1)
+ rel_ba = _rel("IfcRelConnectsPathElements", relating=elem_b, rel_id=2)
+ elem_a.ConnectedTo = [rel_ab]
+ elem_a.ConnectedFrom = [rel_ba]
+
+ rels = tool.Connection.find_rels(elem_a, elem_b)
+
+ assert {r.id() for r, _ in rels} == {1, 2}
+ assert all(k == "path" for _, k in rels)
+
+
+def test_find_rels_classifies_top_element_rel_specifically():
+ """IfcRelConnectsElements with Description=='TOP' is the rel kind
+ extend_walls_to_underside creates. Tag it ``element-top`` so the
+ operator can dispatch the regenerate-wall-to-underside cleanup."""
+ wall = _elem()
+ slab = _elem()
+ rel = _rel("IfcRelConnectsElements", relating=slab, description="TOP", rel_id=1)
+ wall.ConnectedFrom = [rel]
+
+ rels = tool.Connection.find_rels(wall, slab)
+
+ assert rels == [(rel, "element-top")]
+
+
+def test_find_rels_classifies_non_top_element_rel_generically():
+ """Other IfcRelConnectsElements descriptions don't get the TOP-specific
+ cleanup. Tag as plain ``element`` so the operator just removes the rel."""
+ elem_a = _elem()
+ elem_b = _elem()
+ rel = _rel("IfcRelConnectsElements", relating=elem_b, description="ATTACHMENT", rel_id=1)
+ elem_a.ConnectedFrom = [rel]
+
+ rels = tool.Connection.find_rels(elem_a, elem_b)
+
+ assert rels == [(rel, "element")]
+
+
+def test_find_rels_returns_empty_when_disconnected():
+ elem_a = _elem()
+ elem_b = _elem()
+ assert tool.Connection.find_rels(elem_a, elem_b) == []
+
+
+def test_find_rels_dedups_by_id():
+ """A rel that surfaces on both ConnectedTo and ConnectedFrom (in
+ pathological IFC files) should not be returned twice."""
+ elem_a = _elem()
+ elem_b = _elem()
+ rel = _rel("IfcRelConnectsPathElements", related=elem_b, relating=elem_b, rel_id=1)
+ elem_a.ConnectedTo = [rel]
+ elem_a.ConnectedFrom = [rel]
+
+ rels = tool.Connection.find_rels(elem_a, elem_b)
+
+ assert len(rels) == 1
+
+
+# ---------------------------------------------------------------------------
+# tool.Connection.find_rels_for_element — single-element entry point
+# ---------------------------------------------------------------------------
+
+
+def test_find_rels_for_element_returns_kind_and_partner_per_rel():
+ """Cascade-on-delete needs every rel touching one element plus the partner
+ element on the other side of each rel — that's the cleanup target."""
+ elem = _elem()
+ partner_a = _elem()
+ partner_b = _elem()
+ rel_path = _rel("IfcRelConnectsPathElements", related=partner_a, rel_id=1)
+ rel_top = _rel("IfcRelConnectsElements", relating=partner_b, description="TOP", rel_id=2)
+ elem.ConnectedTo = [rel_path]
+ elem.ConnectedFrom = [rel_top]
+
+ result = tool.Connection.find_rels_for_element(elem)
+
+ assert (rel_path, "path", partner_a) in result
+ assert (rel_top, "element-top", partner_b) in result
+ assert len(result) == 2
+
+
+def test_find_rels_for_element_dedups_by_rel_id():
+ elem = _elem()
+ partner = _elem()
+ rel = _rel("IfcRelConnectsPathElements", related=partner, relating=partner, rel_id=1)
+ elem.ConnectedTo = [rel]
+ elem.ConnectedFrom = [rel]
+
+ result = tool.Connection.find_rels_for_element(elem)
+
+ assert len(result) == 1
+
+
+def test_find_rels_for_element_skips_rels_without_partner():
+ """Defensive: a malformed rel missing the opposite-side attribute should not
+ crash — record nothing for it rather than emit a (rel, kind, None) triple
+ that would later trip a None-deref in the dispatch."""
+ elem = _elem()
+ bad = _rel("IfcRelConnectsPathElements", related=None, rel_id=1)
+ elem.ConnectedTo = [bad]
+
+ assert tool.Connection.find_rels_for_element(elem) == []
+
+
+# ---------------------------------------------------------------------------
+# tool.Connection.find_rel — first-match convenience
+# ---------------------------------------------------------------------------
+
+
+def test_find_rel_returns_first_match_or_none_none():
+ elem_a = _elem()
+ elem_b = _elem()
+ rel = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1)
+ elem_a.ConnectedTo = [rel]
+
+ assert tool.Connection.find_rel(elem_a, elem_b) == (rel, "path")
+ assert tool.Connection.find_rel(elem_a, _elem()) == (None, None)
+
+
+# ---------------------------------------------------------------------------
+# tool.Connection.orient_element_top — wall / slab orientation recovery
+# ---------------------------------------------------------------------------
+
+
+def test_orient_element_top_returns_wall_then_slab():
+ """The TOP rel stores slab as relating + wall as related; orient_element_top
+ figures out which input is which regardless of argument order."""
+ wall = _elem()
+ slab = _elem()
+ rel = _rel("IfcRelConnectsElements", relating=slab, related=wall, description="TOP")
+
+ assert tool.Connection.orient_element_top(rel, wall, slab) == (wall, slab)
+ assert tool.Connection.orient_element_top(rel, slab, wall) == (wall, slab)
+
+
+# ---------------------------------------------------------------------------
+# bim.disconnect_elements — dispatch + cleanup
+# ---------------------------------------------------------------------------
+
+
+def _make_op(*, a_guid="A", b_guid="B"):
+ op = Mock()
+ op.element_a_guid = a_guid
+ op.element_b_guid = b_guid
+ op.report = Mock()
+ return op
+
+
+def test_disconnect_dispatches_one_call_per_rel():
+ """Operator forwards every rel returned by find_rels to disconnect_rel,
+ in order — the operator is a thin wrapper; per-kind cleanup logic lives
+ in core.connection.disconnect_rel and is tested separately."""
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ elem_a = Mock()
+ elem_b = Mock()
+ rel1 = Mock()
+ rel2 = Mock()
+
+ ifc_file = MagicMock()
+ ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
+ op = _make_op()
+
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels",
+ return_value=[(rel1, "path"), (rel2, "element-top")],
+ ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch(
+ "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
+ ), patch(
+ "bonsai.bim.module.model.wall._resync_walls_after_mutation"
+ ), patch(
+ "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
+ ):
+ DisconnectElements._perform(op, context=MagicMock())
+
+ assert dispatch.call_count == 2
+ # Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation
+ # — orient_element_top inside disconnect_rel recovers the wall/slab roles.
+ for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
+ kw = call.kwargs
+ assert kw["rel"] is expected_rel
+ assert kw["kind"] == expected_kind
+ assert kw["elem"] is elem_a
+ assert kw["partner"] is elem_b
+ op.report.assert_not_called()
+
+
+def test_disconnect_resyncs_path_objs_once_for_path_kind():
+ """For path rels the operator collects both endpoint objects and resyncs
+ drafts once at the end — a Blender-side concern that doesn't belong in
+ the core dispatch."""
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ elem_a = Mock()
+ elem_b = Mock()
+ obj_a = Mock()
+ obj_b = Mock()
+ rel = Mock()
+
+ ifc_file = MagicMock()
+ ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
+ op = _make_op()
+
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")]
+ ), patch(
+ "bonsai.bim.module.model.wall.tool.Ifc.get_object",
+ side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e],
+ ), patch(
+ "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
+ ), patch(
+ "bonsai.bim.module.model.wall._resync_walls_after_mutation"
+ ) as resync, patch(
+ "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
+ ):
+ DisconnectElements._perform(op, context=MagicMock())
+
+ resync.assert_called_once_with([obj_a, obj_b])
+
+
+def test_disconnect_skips_resync_for_non_path_kind():
+ """element-top / element kinds don't need wall-draft resync — that's a
+ path-specific concern (DumbWallJoiner geometry refresh)."""
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ elem_a = Mock()
+ elem_b = Mock()
+ rel = Mock()
+
+ ifc_file = MagicMock()
+ ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
+ op = _make_op()
+
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")]
+ ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
+ "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
+ ), patch(
+ "bonsai.bim.module.model.wall._resync_walls_after_mutation"
+ ) as resync, patch(
+ "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
+ ):
+ DisconnectElements._perform(op, context=MagicMock())
+
+ resync.assert_not_called()
+
+
+def test_disconnect_gizmo_direction_symmetry():
+ """The wall-selected gizmo dispatches with element_a=wall, element_b=slab.
+ The slab-selected gizmo dispatches with element_a=slab, element_b=wall.
+ Both routes hit disconnect_rel with the same (rel, kind) pair — orientation
+ recovery happens inside the dispatch, not at the operator layer."""
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ wall = Mock(name="wall")
+ slab = Mock(name="slab")
+ rel = Mock()
+
+ ifc_file = MagicMock()
+ op = _make_op()
+
+ def _run_with_guids(a, b):
+ ifc_file.by_guid.side_effect = lambda g: {a: wall if a == "WALL" else slab, b: slab if b == "SLAB" else wall}[g]
+ op.element_a_guid = a
+ op.element_b_guid = b
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")]
+ ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
+ "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
+ ) as dispatch, patch(
+ "bonsai.bim.module.model.wall._resync_walls_after_mutation"
+ ), patch(
+ "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
+ ):
+ DisconnectElements._perform(op, context=MagicMock())
+ return dispatch.call_args.kwargs
+
+ wall_first = _run_with_guids("WALL", "SLAB")
+ slab_first = _run_with_guids("SLAB", "WALL")
+
+ # disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap
+ # by argument order but orient_element_top inside disconnect_rel resolves
+ # the wall/slab roles symmetrically.
+ assert wall_first["rel"] is rel and slab_first["rel"] is rel
+ assert wall_first["kind"] == slab_first["kind"] == "element-top"
+ assert {wall_first["elem"], wall_first["partner"]} == {wall, slab}
+ assert {slab_first["elem"], slab_first["partner"]} == {wall, slab}
+
+
+def test_disconnect_reports_on_unknown_guids():
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ ifc_file = MagicMock()
+ ifc_file.by_guid.side_effect = RuntimeError("missing")
+ op = _make_op(a_guid="MISSING_A", b_guid="MISSING_B")
+
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels"
+ ) as find:
+ DisconnectElements._perform(op, context=MagicMock())
+
+ find.assert_not_called()
+ op.report.assert_called_once()
+ args, _ = op.report.call_args
+ assert args[0] == {"ERROR"}
+
+
+def test_disconnect_reports_when_no_rel_found():
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ elem_a = Mock()
+ elem_b = Mock()
+ ifc_file = MagicMock()
+ ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
+ op = _make_op()
+
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[]
+ ):
+ DisconnectElements._perform(op, context=MagicMock())
+
+ op.report.assert_called_once()
+
+
+def test_disconnect_operator_is_registered():
+ from bonsai.bim.module import model
+
+ assert any(
+ getattr(cls, "bl_idname", None) == "bim.disconnect_elements" for cls in model.classes
+ ), "DisconnectElements is not in the model classes tuple"
+
+
+def test_disconnect_refuses_path_kind_when_either_side_is_fillet():
+ """The fillet corner's join with its source walls defines its identity
+ — unjoining there would tear down the chord axis reference. The
+ operator reports an INFO directing the user to delete the corner
+ wall and skips the dispatch entirely."""
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ fillet = Mock(name="fillet_corner")
+ wall = Mock(name="source_wall")
+ rel = Mock()
+
+ ifc_file = MagicMock()
+ ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": wall}[g]
+ op = _make_op()
+
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")]
+ ), patch(
+ "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall",
+ side_effect=lambda e: e is fillet,
+ ), patch(
+ "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
+ ) as dispatch:
+ DisconnectElements._perform(op, context=MagicMock())
+
+ dispatch.assert_not_called()
+ op.report.assert_called_once()
+ args, _ = op.report.call_args
+ assert args[0] == {"INFO"}
+
+
+def test_disconnect_allows_slab_kind_even_when_wall_is_fillet():
+ """The fillet ↔ slab underside clip is a different relationship from
+ the fillet ↔ source-wall path join. Slab disconnect must remain
+ available while the corner is in preview."""
+ from bonsai.bim.module.model.wall import DisconnectElements
+
+ fillet = Mock(name="fillet_corner")
+ slab = Mock(name="slab")
+ rel = Mock()
+
+ ifc_file = MagicMock()
+ ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": slab}[g]
+ op = _make_op()
+
+ with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
+ "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")]
+ ), patch(
+ "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall",
+ side_effect=lambda e: e is fillet,
+ ), patch(
+ "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
+ ), patch(
+ "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
+ ) as dispatch, patch(
+ "bonsai.bim.module.model.wall._resync_walls_after_mutation"
+ ):
+ DisconnectElements._perform(op, context=MagicMock())
+
+ dispatch.assert_called_once()
diff --git a/src/bonsai/test/bim/module/model/test_merge_wall_convention.py b/src/bonsai/test/bim/module/model/test_merge_wall_convention.py
new file mode 100644
index 0000000000..6447b8541b
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_merge_wall_convention.py
@@ -0,0 +1,87 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Pins the active-is-survivor merge convention.
+
+``bim.merge_wall`` must consume the non-active selection into the active
+one — matching Blender's ``OBJECT_OT_join`` / ``MESH_OT_merge`` "at
+last" convention. Users following Ctrl+J muscle-memory click the
+surviving wall last; the operator must align with that expectation."""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+pytestmark = pytest.mark.wall
+
+
+def _run_perform(active, other):
+ """Invoke ``MergeWall._perform`` as an unbound function with the
+ two wall stubs in the selection, patching the heavy IFC / Blender
+ side effects. Returns the ``(merger_arg_1, merger_arg_2)`` actually
+ passed to ``DumbWallJoiner.merge``."""
+ from bonsai.bim.module.model.wall import MergeWall
+
+ context = SimpleNamespace(active_object=active)
+ captured_call = {}
+
+ def _capture_merge(self, a, b):
+ captured_call["wall1"] = a
+ captured_call["wall2"] = b
+
+ fake_self = SimpleNamespace()
+
+ with (
+ patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
+ patch("bonsai.bim.module.model.wall.tool.Model.get_selected_mesh_objects", return_value=[active, other]),
+ patch("bonsai.bim.module.model.wall.DumbWallJoiner.__init__", return_value=None),
+ patch("bonsai.bim.module.model.wall.DumbWallJoiner.merge", new=_capture_merge),
+ patch("bonsai.bim.module.model.wall._maybe_resync_wall_props_from_ifc"),
+ patch("bonsai.bim.module.model.wall._regenerate_walls") as regen_walls,
+ ):
+ result = MergeWall._perform(fake_self, context)
+
+ return captured_call, regen_walls, result
+
+
+def test_active_wall_is_passed_as_survivor_to_merge():
+ """The first argument to ``DumbWallJoiner.merge`` is the survivor;
+ the active object must occupy that slot so the wall the user clicked
+ last absorbs the other."""
+ active = SimpleNamespace(name="active")
+ other = SimpleNamespace(name="other")
+
+ captured, _regen, _ = _run_perform(active, other)
+
+ assert captured["wall1"] is active
+ assert captured["wall2"] is other
+
+
+def test_post_merge_resync_targets_active_not_consumed():
+ """After the merge ``_regenerate_walls`` rebuilds the survivor's
+ body. Targeting the consumed wall would crash on a freed ``bpy_struct``;
+ the survivor (active) is the only valid target."""
+ active = SimpleNamespace(name="active")
+ other = SimpleNamespace(name="other")
+
+ _, regen_walls, _ = _run_perform(active, other)
+
+ regen_walls.assert_called_once_with([active])
diff --git a/src/bonsai/test/bim/module/model/test_railing_lifecycle.py b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py
new file mode 100644
index 0000000000..0aad3534b7
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py
@@ -0,0 +1,300 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle
+behaviour railing inherits from ``PathPreservingEditMixin``.
+
+The parent short-circuit (skip the IFC commit / viewport rebuild when the
+draft is identical to the stored pset) lives in
+``PathPreservingEditMixin``; the tests below verify railing's subclass
+honours that contract by inheritance, then pin the railing-specific
+viewport-restore dispatch:
+
+- Finish / Cancel no-op short-circuit: inherited from the parent — verified
+ here because railing was the original consumer that motivated the
+ optimisation.
+- ``_RailingEditMixin._restore_viewport_after_cancel`` dispatch: WALL_MOUNTED_HANDRAIL
+ reloads the high-poly Body representation via ``switch_representation``;
+ FRAMELESS_PANEL rebuilds the bmesh preview via
+ ``update_railing_modifier_bmesh``. This is the per-type branch that used
+ to live in ``_cancel_one`` and now lives in the viewport-restore hook the
+ parent's ``_cancel_one`` calls.
+"""
+
+from unittest import mock
+
+import pytest
+
+from test.bim.conftest import _FakePropsBase
+from test.bim.conftest import make_lifecycle_obj as _make_obj
+
+pytestmark = pytest.mark.model
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+class _FakeRailingProps(_FakePropsBase):
+ """Stand-in for ``BIMRailingProperties`` — adds ``railing_type`` on top of
+ the shared parametric-edit contract. Starts in ``is_editing=True`` because
+ the railing-specific overrides under test only fire on Finish / Cancel,
+ not on Enable."""
+
+ def __init__(self, railing_type: str = "WALL_MOUNTED_HANDRAIL", general: dict | None = None):
+ super().__init__(general=general if general is not None else {"railing_type": railing_type, "height": 1.0})
+ self.railing_type = railing_type
+ self.is_editing = True
+
+
+@pytest.fixture
+def patched_railing():
+ """Patch the railing module's external references for unit testing.
+
+ ``_RailingEditMixin`` and the parent lifecycle reach for
+ ``tool.Model.get_modeling_bbim_pset_data``, ``tool.Ifc.get_entity``,
+ ``ifcopenshell.util.representation.get_representation``,
+ ``bonsai.core.geometry.switch_representation``, and the module-level
+ ``update_railing_modifier_bmesh`` — each looked up through the railing
+ module's own bindings, so we patch them there.
+
+ ``parametric_lifecycle.tool`` is patched separately so the parent's
+ ``_resolve`` and ``_cancel_one`` can read ``tool.Model.get_modeling_bbim_pset_data``
+ without falling through to the real Blender bindings.
+
+ Uses ``mock.patch.object`` with a direct module reference rather than
+ the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")``
+ needs ``pkgutil.resolve_name`` to traverse ``bonsai → bim → module → …``,
+ which fails at the ``bonsai.bim`` step until that subpackage has been
+ imported elsewhere. The direct-object form sidesteps the resolution.
+
+ Returns a dict for tests to seed return values and assert call sites.
+ """
+ from bonsai.bim import parametric_lifecycle
+ from bonsai.bim.module.model import railing
+
+ with (
+ mock.patch.object(railing, "tool") as mock_tool,
+ mock.patch.object(railing, "ifcopenshell") as mock_ifc,
+ mock.patch.object(railing, "bonsai") as mock_bonsai,
+ mock.patch.object(railing, "update_railing_modifier_bmesh") as mock_update_bmesh,
+ mock.patch.object(parametric_lifecycle, "tool") as mock_pl_tool,
+ ):
+ # _resolve will be overridden on the test subclass below so the
+ # parametric_lifecycle.tool patch isn't needed for that path, but the
+ # parent's _cancel_one / _finish_one still call
+ # tool.Model.get_modeling_bbim_pset_data and would otherwise miss.
+ mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
+ yield {
+ "tool": mock_tool,
+ "ifcopenshell": mock_ifc,
+ "bonsai": mock_bonsai,
+ "update_bmesh": mock_update_bmesh,
+ "pl_tool": mock_pl_tool,
+ }
+
+
+def _railing_test_subclass(props):
+ """Build a ``_RailingEditMixin`` subclass that bypasses ``_resolve``.
+
+ The base ``_resolve`` reads ``tool.Ifc.get_entity`` from
+ ``parametric_lifecycle.tool`` (a separate import from the railing
+ module's ``tool``). Overriding it here keeps the test patches local
+ to the railing module and the hook closures local to the test."""
+ from bonsai.bim.module.model.railing import _RailingEditMixin
+
+ test_element = mock.Mock(name="ifc_element")
+
+ class _TestRailingMixin(_RailingEditMixin):
+ pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset")
+ ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data")
+ bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel")
+
+ @classmethod
+ def _resolve(cls, obj):
+ return test_element, props
+
+ @classmethod
+ def _update_pset(cls, element, data):
+ cls.pset_updates(element, data)
+
+ @classmethod
+ def _update_modifier_ifc_data(cls, obj, context):
+ cls.ifc_data_updates(obj, context)
+
+ @classmethod
+ def _restore_viewport_after_cancel(cls, obj, context):
+ cls.bmesh_updates(obj, context)
+
+ # The base _post_load_data JSON-serialises path_data; bypass that
+ # here so the round-trip stays a plain dict and tests can compare
+ # by reference / equality without re-parsing.
+ @classmethod
+ def _post_load_data(cls, data):
+ return dict(data)
+
+ return _TestRailingMixin, test_element
+
+
+# ---------------------------------------------------------------------------
+# _RailingEditMixin._finish_one
+# ---------------------------------------------------------------------------
+
+
+def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing):
+ """Enable → Finish without any property edit must NOT write to IFC.
+
+ Without this, every "open Edit, click Validate immediately" cycle
+ would create a fresh ``IfcShapeRepresentation``, pollute the file's
+ representation list, and burn an undo entry — the user-visible
+ regression that motivated the short-circuit.
+
+ Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
+ the coverage as the original consumer of the contract.
+ """
+ stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
+ props = _FakeRailingProps(general=dict(stored))
+ obj = _make_obj(props)
+ patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
+ "data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
+ }
+
+ cls, _element = _railing_test_subclass(props)
+ cls._finish_one(obj, mock.Mock(name="context"))
+
+ assert props.is_editing is False, "is_editing must still flip even on no-op"
+ cls.pset_updates.assert_not_called()
+ cls.ifc_data_updates.assert_not_called()
+
+
+def test_finish_one_writes_when_draft_differs(patched_railing):
+ """The complement of the short-circuit: a real property change must
+ flow through to ``_update_pset`` + ``_update_modifier_ifc_data``."""
+ stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
+ # Draft height differs: simulating a user edit.
+ props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5})
+ obj = _make_obj(props)
+ patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
+ "data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
+ }
+
+ cls, element = _railing_test_subclass(props)
+ cls._finish_one(obj, mock.Mock(name="context"))
+
+ assert props.is_editing is False
+ cls.pset_updates.assert_called_once()
+ # The pset must receive the DRAFT data, not the stored data — that's the
+ # whole point of Finish committing the user's edits.
+ written = cls.pset_updates.call_args[0][1]
+ assert written["height"] == 1.5
+ cls.ifc_data_updates.assert_called_once_with(obj, mock.ANY)
+
+
+# ---------------------------------------------------------------------------
+# _RailingEditMixin._cancel_one
+# ---------------------------------------------------------------------------
+
+
+def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
+ """Cancel-without-changes is asymmetrically expensive without this guard:
+ ``switch_representation`` re-tessellates the IfcSweptDiskSolid and is
+ visibly slow on a long handrail. When nothing changed, the mesh on
+ screen is still the committed IFC representation (the preview only
+ builds on a property change) — skip the reload entirely.
+
+ Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
+ the coverage as the original consumer of the contract.
+ """
+ stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
+ props = _FakeRailingProps(general=dict(stored))
+ obj = _make_obj(props)
+ patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
+ "data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
+ }
+
+ cls, _element = _railing_test_subclass(props)
+ cls._cancel_one(obj, mock.Mock(name="context"))
+
+ assert props.is_editing is False
+ patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
+ patched_railing["update_bmesh"].assert_not_called()
+ cls.bmesh_updates.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# _RailingEditMixin._restore_viewport_after_cancel — per-type viewport-restore dispatch
+#
+# The parent's _cancel_one calls cls._restore_viewport_after_cancel whenever
+# the draft differs from the stored pset. Railing's override branches on
+# railing_type so WALL_MOUNTED_HANDRAIL reloads the high-poly Body
+# representation rather than rebuilding the low-poly cylinder-segment preview.
+# ---------------------------------------------------------------------------
+
+
+def test_restore_viewport_wall_mounted_handrail_switches_representation(patched_railing):
+ """WALL_MOUNTED_HANDRAIL restore must call ``switch_representation`` with
+ the Body representation — the preview is viewport-only (low-poly cylinder)
+ and would persist visibly without the reload."""
+ from bonsai.bim.module.model.railing import _RailingEditMixin
+
+ props = _FakeRailingProps(railing_type="WALL_MOUNTED_HANDRAIL")
+ obj = _make_obj(props)
+ patched_railing["tool"].Model.get_railing_props.return_value = props
+ body_repr = mock.Mock(name="body_representation")
+ patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
+
+ _RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context"))
+
+ patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once()
+ kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs
+ assert kwargs["obj"] is obj
+ assert kwargs["representation"] is body_repr
+ # Must NOT fall through to the FRAMELESS bmesh-rebuild path.
+ patched_railing["update_bmesh"].assert_not_called()
+
+
+def test_restore_viewport_frameless_panel_calls_module_bmesh_rebuild(patched_railing):
+ """FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
+ swept-disk solid to reload. The restore must delegate to the module-level
+ ``update_railing_modifier_bmesh`` rebuilder rather than swap representations."""
+ from bonsai.bim.module.model.railing import _RailingEditMixin
+
+ props = _FakeRailingProps(railing_type="FRAMELESS_PANEL")
+ obj = _make_obj(props)
+ patched_railing["tool"].Model.get_railing_props.return_value = props
+ ctx = mock.Mock(name="context")
+
+ _RailingEditMixin._restore_viewport_after_cancel(obj, ctx)
+
+ patched_railing["update_bmesh"].assert_called_once_with(ctx)
+ patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# _get_railing_path_anchor: tests removed.
+#
+# The schematic-redesign branch replaced ``GizmoRailingEdition`` with
+# ``GizmoRailingSchematic``, which anchors via the schematic frame rather
+# than the polyline's first vertex. ``_get_railing_path_anchor`` was the
+# helper for the old anchor strategy and has been deleted along with the
+# old gizmo group. If schematic-mode gains a similar path-derived helper,
+# new tests should land here.
+# ---------------------------------------------------------------------------
diff --git a/src/bonsai/test/bim/module/model/test_railing_schematic.py b/src/bonsai/test/bim/module/model/test_railing_schematic.py
new file mode 100644
index 0000000000..7ba12a7a70
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_railing_schematic.py
@@ -0,0 +1,270 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+import types
+from types import SimpleNamespace
+
+import bmesh
+import bpy
+import pytest
+
+from bonsai import tool
+from bonsai.bim.module.drawing.gizmos import (
+ BaseSchematicGizmoGroup,
+ DimensionGizmoConfig,
+)
+from bonsai.bim.module.model.railing import GizmoRailingSchematic
+
+pytestmark = pytest.mark.model
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ """Skip the file when ``bpy`` is mocked or absent.
+
+ Without this guard, mis-routed test runs (e.g. ``pytest test/bim/...``
+ invoked outside Blender) crash at module-collection time on the chain of
+ ``bonsai.tool`` imports below, instead of producing a clean ``skipped``.
+ """
+ if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+# ── Class shape ──────────────────────────────────────────────────────────────
+
+
+def test_railing_schematic_inherits_base():
+ """GizmoRailingSchematic plugs into the schematic framework, not the
+ in-place dimension framework. If a future refactor breaks this lineage
+ the schematic-specific machinery (sliders, draw handler) silently goes
+ dormant."""
+ assert issubclass(GizmoRailingSchematic, BaseSchematicGizmoGroup)
+
+
+def test_railing_schematic_bl_idname_preserved():
+ """``OBJECT_GGT_bim_railing_edition`` is the user-facing identifier and
+ is referenced by keymaps and persistence. Preserve it across the class
+ rename — see the migration note in the class docstring."""
+ assert GizmoRailingSchematic.bl_idname == "OBJECT_GGT_bim_railing_edition"
+
+
+def test_railing_schematic_props_getter_pairing():
+ """``gizmo_pref_name = "railing"`` and ``props_getter = tool.Model.get_railing_props``
+ are the pairing test_parametric_registry depends on. If either drifts,
+ the addon-preferences gizmo toggle silently stops controlling this group."""
+ assert GizmoRailingSchematic.gizmo_pref_name == "railing"
+ assert GizmoRailingSchematic.props_getter == tool.Model.get_railing_props
+
+
+def test_railing_schematic_disables_in_place_dimension_props():
+ """The schematic owns the value-input surface — no in-place dimensions on the actual geometry."""
+ assert GizmoRailingSchematic.dimension_gizmo_props == []
+
+
+# ── Dimension configuration ─────────────────────────────────────────────────
+
+
+def test_railing_schematic_has_six_dimensions():
+ """One dimension per parametric property — three for each railing_type."""
+ assert len(GizmoRailingSchematic.schematic_dimension_props) == 6
+
+
+def test_railing_schematic_dimension_attr_names_complete():
+ """The six bound attributes match the parametric properties that
+ ``update_railing_modifier_bmesh`` reads when regenerating the live preview."""
+ attr_names = {c.attr_name for c in GizmoRailingSchematic.schematic_dimension_props}
+ assert attr_names == {
+ "height",
+ "thickness",
+ "spacing",
+ "railing_diameter",
+ "clear_width",
+ "support_spacing",
+ }
+
+
+def test_railing_schematic_dimensions_are_dimension_configs():
+ """The dimension-line aesthetic depends on ``DimensionGizmoConfig`` (with
+ arrows + label), not the abstract slider widget."""
+ for config in GizmoRailingSchematic.schematic_dimension_props:
+ assert isinstance(config, DimensionGizmoConfig)
+
+
+def test_railing_schematic_dimensions_have_text_formatters():
+ """Each dimension must format the label from the actual property value,
+ not from the visually-scaled value the gizmo's getter returns. Without a
+ formatter the label would show the schematic-scaled length, which is
+ meaningless to the user."""
+ for config in GizmoRailingSchematic.schematic_dimension_props:
+ assert config.text_formatter is not None, f"{config.attr_name} missing text_formatter"
+
+
+@pytest.mark.parametrize(
+ "attr_name,railing_type,expected",
+ [
+ ("height", "FRAMELESS_PANEL", True),
+ ("height", "WALL_MOUNTED_HANDRAIL", False),
+ ("thickness", "FRAMELESS_PANEL", True),
+ ("spacing", "FRAMELESS_PANEL", True),
+ ("railing_diameter", "WALL_MOUNTED_HANDRAIL", True),
+ ("railing_diameter", "FRAMELESS_PANEL", False),
+ ("clear_width", "WALL_MOUNTED_HANDRAIL", True),
+ ],
+)
+def test_railing_schematic_dimension_visibility_gated_by_railing_type(attr_name, railing_type, expected):
+ """The two railing types are mutually exclusive — height/thickness/spacing
+ belong to FRAMELESS_PANEL; railing_diameter/clear_width/support_spacing
+ belong to WALL_MOUNTED_HANDRAIL. The visibility lambdas enforce that."""
+ config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == attr_name)
+ props = SimpleNamespace(railing_type=railing_type, use_manual_supports=False)
+ assert config.visibility_condition(props) is expected
+
+
+def test_railing_schematic_support_spacing_hidden_for_manual_supports():
+ """``support_spacing`` only drives auto-positioned supports — when the
+ user has switched to manual supports the dimension should disappear."""
+ config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == "support_spacing")
+ auto = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=False)
+ manual = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=True)
+ assert config.visibility_condition(auto) is True
+ assert config.visibility_condition(manual) is False
+
+
+# ── Fixed-length tag rendering ─────────────────────────────────────────────
+
+
+def test_schematic_dim_visible_length_is_constant():
+ """Every schematic dimension tag renders at the same width — the bar is a
+ UI affordance, not a proportional measurement. The constant ratio keeps
+ tiny (5 mm thickness) and huge (5 m height) values equally clickable; the
+ real value lives in the dimension label.
+
+ Regression guard: if value-proportional scaling is reintroduced, this
+ contract breaks silently — small dimensions start collapsing into stacked
+ arrows again.
+ """
+ cls = GizmoRailingSchematic
+ ratio = cls.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO
+ assert ratio > 0
+ assert ratio <= 1.0 # bar must fit within the schematic box
+
+
+def test_schematic_no_compute_schematic_scale_override():
+ """The constant-length schematic must not reintroduce scale-based
+ proportional sizing via a ``_compute_schematic_scale`` override."""
+ assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__
+
+
+# ── Path-edit guard ─────────────────────────────────────────────────────────
+
+
+def test_update_editing_gizmos_override_defined_on_subclass():
+ """``GizmoRailingSchematic`` must own the override that hides the pen
+ icon during path-edit. The parent's version shows the pen whenever
+ ``is_editing`` is False, which includes path-edit; that would let the
+ user open two editing modes at once."""
+ assert "update_editing_gizmos" in GizmoRailingSchematic.__dict__
+
+
+# ── Schematic mesh building ─────────────────────────────────────────────────
+
+
+def test_build_schematic_mesh_frameless_panel_returns_bmesh_with_edges():
+ """FRAMELESS_PANEL renders as two separated wireframe boxes — 8 corners
+ per box × 2 = 16 verts; 12 edges per box × 2 = 24 edges. The visible
+ gap between the two boxes is the "spacing" semantic made literal.
+
+ The mesh proportions are fixed (independent of property values) so the
+ dimension gizmos can anchor to known feature positions; the property
+ values are shown through dimension labels, not the mesh size."""
+ props = SimpleNamespace(
+ railing_type="FRAMELESS_PANEL",
+ height=1.0,
+ thickness=0.05,
+ spacing=0.5,
+ )
+ bm = GizmoRailingSchematic.build_schematic_mesh(props)
+ try:
+ assert isinstance(bm, bmesh.types.BMesh)
+ assert len(bm.verts) == 16
+ assert len(bm.edges) == 24
+ finally:
+ bm.free()
+
+
+def test_build_schematic_mesh_wall_mounted_handrail_returns_bmesh_with_edges():
+ """WALL_MOUNTED_HANDRAIL renders as three visual elements:
+
+ - **Wall outline** — 4 corner verts, 4 edges (rectangle at z=0).
+ - **Hex tube** — 12 verts (6 per ring × 2 ends), 18 edges
+ (6 left ring + 6 right ring + 6 axial).
+ - **L-brackets** at each rail end — 3 verts per bracket (rail centre,
+ corner, wall attach) × 2 brackets = 6 verts; 2 edges per bracket
+ (rail→corner, corner→wall) × 2 = 4 edges.
+
+ Total: 22 verts, 26 edges.
+ """
+ props = SimpleNamespace(
+ railing_type="WALL_MOUNTED_HANDRAIL",
+ railing_diameter=0.05,
+ clear_width=0.04,
+ support_spacing=1.0,
+ )
+ bm = GizmoRailingSchematic.build_schematic_mesh(props)
+ try:
+ assert isinstance(bm, bmesh.types.BMesh)
+ assert len(bm.verts) == 22
+ assert len(bm.edges) == 26
+ finally:
+ bm.free()
+
+
+def test_build_schematic_mesh_proportions_independent_of_props():
+ """The mesh uses fixed proportions so dimension gizmo anchor points stay
+ aligned with the geometry — extreme prop ratios don't change the mesh."""
+ small = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=0.01, thickness=0.005, spacing=0.05)
+ large = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=10.0, thickness=0.5, spacing=2.0)
+ bm_small = GizmoRailingSchematic.build_schematic_mesh(small)
+ bm_large = GizmoRailingSchematic.build_schematic_mesh(large)
+ try:
+ # Same vert count regardless of prop magnitude.
+ assert len(bm_small.verts) == len(bm_large.verts)
+ # Same bounding box in each axis (within floating-point noise).
+ for axis in range(3):
+ small_coords = [v.co[axis] for v in bm_small.verts]
+ large_coords = [v.co[axis] for v in bm_large.verts]
+ assert min(small_coords) == pytest.approx(min(large_coords))
+ assert max(small_coords) == pytest.approx(max(large_coords))
+ finally:
+ bm_small.free()
+ bm_large.free()
+
+
+def test_build_schematic_mesh_panel_top_matches_height_frac():
+ """The panel's top edge sits at exactly ``SCHEMATIC_MESH_HEIGHT_FRAC``,
+ which is also where the ``thickness`` dimension anchors above the box.
+ If this drifts, the dimension labels float disconnected from the mesh."""
+ props = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=1.0, thickness=0.05, spacing=0.3)
+ bm = GizmoRailingSchematic.build_schematic_mesh(props)
+ try:
+ max_y = max(v.co.y for v in bm.verts)
+ assert max_y == pytest.approx(GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC)
+ finally:
+ bm.free()
diff --git a/src/bonsai/test/bim/module/model/test_regenerate_wall.py b/src/bonsai/test/bim/module/model/test_regenerate_wall.py
new file mode 100644
index 0000000000..68ca4bcdf9
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_regenerate_wall.py
@@ -0,0 +1,85 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Pins the branching contract of ``tool.Model.regenerate_wall``.
+
+The body rebuild always runs (extrusion + openings); the slab re-clip only
+runs when an ``IfcRelConnectsElements(TOP)`` rel survives. A wall without
+either feature still completes without crashing."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+import bonsai.tool as tool
+
+pytestmark = pytest.mark.model
+
+
+def test_regenerate_wall_rebuilds_body_and_reclips_when_connected():
+ """Wall with a TOP connection: body rebuilt first, then re-clipped."""
+ element = Mock()
+ obj = Mock()
+
+ with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
+ tool.Model, "recreate_wall"
+ ) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=True), patch(
+ "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
+ ) as regen:
+ tool.Model.regenerate_wall(obj)
+
+ recreate.assert_called_once_with(element, obj)
+ regen.assert_called_once()
+ args, _ = regen.call_args
+ assert args[3] == [obj]
+
+
+def test_regenerate_wall_skips_reclip_when_no_top_rel():
+ """Wall without a TOP connection: body rebuilt; re-clip skipped."""
+ element = Mock()
+ obj = Mock()
+
+ with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
+ tool.Model, "recreate_wall"
+ ) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=False), patch(
+ "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
+ ) as regen:
+ tool.Model.regenerate_wall(obj)
+
+ recreate.assert_called_once_with(element, obj)
+ regen.assert_not_called()
+
+
+def test_regenerate_wall_noops_when_obj_has_no_ifc_entity():
+ """Non-IFC objects (e.g. a freshly created Blender mesh before
+ `tool.Ifc.run("root.create_entity")` runs) return None from get_entity;
+ the helper must return without touching the body or any rels."""
+ obj = Mock()
+
+ with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=None), patch.object(
+ tool.Model, "recreate_wall"
+ ) as recreate, patch.object(tool.Model, "has_underside_connection") as has_top, patch(
+ "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
+ ) as regen:
+ tool.Model.regenerate_wall(obj)
+
+ recreate.assert_not_called()
+ has_top.assert_not_called()
+ regen.assert_not_called()
diff --git a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py
index 6c1b367b7c..6e4a7fdca9 100644
--- a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py
+++ b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py
@@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule):
- ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base
parametric poll already filters array children.
+- ``GizmoSlabEdition`` — same as ``GizmoWallEdition`` (inherits
+ ``BaseParametricGizmoGroup`` whose poll filters array children).
- ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire
WHILE its own preview is active; routing it through the topology gate
would self-block it.
@@ -47,9 +49,11 @@ pytestmark = pytest.mark.model
# Wall gizmo groups intentionally outside the rule. Add a new entry only
# with the in-code reasoning above.
-_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
+_ALLOWLIST = frozenset({"GizmoSlabEdition", "GizmoWallEdition", "GizmoWallFilletPreview"})
-_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
+_REQUIRED_CALLEES = frozenset(
+ {"_wall_topology_gizmo_poll_gate", "_slab_connection_gizmo_poll_gate", "any_selected_is_array_child"}
+)
def _wall_module_source():
diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_gizmos.py
index c3b97e9466..4bdd78ed4a 100644
--- a/src/bonsai/test/bim/module/model/test_wall_gizmos.py
+++ b/src/bonsai/test/bim/module/model/test_wall_gizmos.py
@@ -50,12 +50,15 @@ def _make_context(active, selected):
return SimpleNamespace(active_object=active, selected_objects=list(selected))
-def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
+def _patch_tools(
+ prefs_on, selected, active_element, other_element, active_usage, other_usage, other_is_path_connectable=None
+):
"""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()."""
+ selection set, the IFC entity lookup, the usage-type lookup, and the
+ path-connectable-wall predicate 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 = {}
@@ -67,12 +70,18 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
usage_map[id(active_element)] = active_usage
usage_map[id(other_element)] = other_usage
+ if other_is_path_connectable is None:
+ other_is_path_connectable = other_usage == "LAYER2"
+
def get_entity(obj):
return entity_map.get(id(obj))
def get_usage_type(element):
return usage_map.get(id(element))
+ def is_path_connectable_wall(element):
+ return element is other_element and other_is_path_connectable
+
from bonsai import tool
return [
@@ -80,6 +89,7 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
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),
+ patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=is_path_connectable_wall),
# The array-child filter is pinned by its own test file; stub it here
# so these poll tests stay focused on the count / layer-usage gates
# and don't have to scaffold the memoization cache key.
@@ -87,7 +97,15 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
]
-def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
+def _run_poll(
+ prefs_on,
+ active_is_in_selected,
+ len_override,
+ active_usage,
+ other_usage,
+ active_has_entity=True,
+ other_is_path_connectable=None,
+):
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
slab_obj = _Obj("slab")
@@ -103,7 +121,15 @@ def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other
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)
+ patches = _patch_tools(
+ prefs_on,
+ selected,
+ slab_element,
+ wall_element,
+ active_usage,
+ other_usage,
+ other_is_path_connectable=other_is_path_connectable,
+ )
for p in patches:
p.start()
try:
@@ -189,6 +215,23 @@ def test_poll_rejects_when_other_is_not_layer2_wall():
)
+def test_poll_accepts_fillet_corner_wall_partner():
+ # Fillet-corner walls carry no LAYER2 usage by spec but the extend-to-
+ # underside operator handles them just like a parametric LAYER2 wall —
+ # the gizmo must surface for the slab + fillet-corner selection too.
+ assert (
+ _run_poll(
+ prefs_on=True,
+ active_is_in_selected=True,
+ len_override=None,
+ active_usage="LAYER3",
+ other_usage=None,
+ other_is_path_connectable=True,
+ )
+ is True
+ )
+
+
# ----------------------------------------------------------------------------
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
# ----------------------------------------------------------------------------
diff --git a/src/bonsai/test/bim/module/model/test_wall_merge_openings.py b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py
new file mode 100644
index 0000000000..27f7779357
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py
@@ -0,0 +1,276 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Pins the contract that ``DumbWallJoiner.merge`` re-hosts openings from
+the discarded wall to the survivor before the cascade delete tears down
+``element2.HasOpenings`` and any filling that references them.
+
+``edit_object_placement`` preserves the opening's world position when the
+two walls have different placements — a ``PlacementRelTo`` swap alone
+would shift the opening as the relative offset changes."""
+
+from unittest.mock import MagicMock, Mock, patch
+
+import numpy as np
+import pytest
+
+pytestmark = pytest.mark.wall
+
+
+def _opening_rel(opening_id: int, placement_matrix: np.ndarray):
+ """Build a stub ``IfcRelVoidsElement`` carrying an opening with a known
+ placement. ``RelatingBuildingElement`` is settable so the test can
+ observe the re-host."""
+ opening = Mock(name=f"opening_{opening_id}")
+ opening.id.return_value = opening_id
+ opening.ObjectPlacement = Mock(name=f"opening_placement_{opening_id}")
+ rel = Mock(name=f"voids_rel_{opening_id}")
+ rel.RelatedOpeningElement = opening
+ rel.RelatingBuildingElement = None
+ return rel, opening, placement_matrix
+
+
+def _merge_inputs(*, has_openings):
+ """Stage the minimum wall1 + wall2 + element1 + element2 surface that
+ ``DumbWallJoiner.merge`` reads. The reference lines and placements are
+ rigged so the collinearity guard passes and execution reaches the
+ opening-migration loop."""
+ wall1 = Mock(name="wall1")
+ wall2 = Mock(name="wall2")
+ element1 = Mock(name="element1")
+ element2 = Mock(name="element2")
+ element1.ObjectPlacement = Mock(name="elem1_placement")
+ element2.ObjectPlacement = Mock(name="elem2_placement")
+ element1.ConnectedTo = []
+ element1.ConnectedFrom = []
+ element2.ConnectedTo = []
+ element2.ConnectedFrom = []
+ element2.HasOpenings = list(has_openings)
+ return wall1, wall2, element1, element2
+
+
+def _run_merge(wall1, wall2, element1, element2, opening_matrices, captured_edit_calls):
+ """Invoke ``DumbWallJoiner().merge`` against the staged inputs with
+ every heavy IFC / Blender side effect patched out. ``opening_matrices``
+ maps an opening id to its captured world matrix; ``captured_edit_calls``
+ is appended to whenever ``edit_object_placement`` fires."""
+ from bonsai.bim.module.model.wall import DumbWallJoiner
+
+ def fake_get_local_placement(placement):
+ for rel in element2.HasOpenings:
+ if rel.RelatedOpeningElement.ObjectPlacement is placement:
+ return opening_matrices[rel.RelatedOpeningElement.id()]
+ return np.eye(4)
+
+ def fake_get_entity(obj):
+ return {wall1: element1, wall2: element2}[obj]
+
+ def fake_edit_object_placement(ifc_file, *, product, matrix, is_si, should_transform_children):
+ captured_edit_calls.append(
+ {
+ "product": product,
+ "matrix": matrix,
+ "is_si": is_si,
+ "should_transform_children": should_transform_children,
+ }
+ )
+
+ p1 = np.array([0.0, 0.0])
+ p2 = np.array([5.0, 0.0])
+ p3 = np.array([5.0, 0.0])
+ p4 = np.array([10.0, 0.0])
+
+ with (
+ patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
+ patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
+ patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
+ patch(
+ "bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
+ side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
+ ),
+ patch(
+ "bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
+ side_effect=fake_get_local_placement,
+ ),
+ patch(
+ "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.edit_object_placement",
+ side_effect=fake_edit_object_placement,
+ ),
+ patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
+ patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object") as delete_ifc_object,
+ patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
+ ):
+ DumbWallJoiner().merge(wall1, wall2)
+ return delete_ifc_object
+
+
+def test_merge_rehosts_each_opening_to_survivor():
+ """Every void rel on the discarded wall is rebound to the survivor so
+ the cascade delete doesn't take them down with element2."""
+ matrix_a = np.eye(4)
+ matrix_a[0, 3] = 1.0
+ matrix_b = np.eye(4)
+ matrix_b[0, 3] = 3.0
+ rel_a, opening_a, _ = _opening_rel(opening_id=101, placement_matrix=matrix_a)
+ rel_b, opening_b, _ = _opening_rel(opening_id=102, placement_matrix=matrix_b)
+ wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel_a, rel_b])
+
+ _run_merge(
+ wall1,
+ wall2,
+ element1,
+ element2,
+ opening_matrices={101: matrix_a, 102: matrix_b},
+ captured_edit_calls=[],
+ )
+
+ assert rel_a.RelatingBuildingElement is element1
+ assert rel_b.RelatingBuildingElement is element1
+
+
+def test_merge_preserves_opening_world_placement():
+ """``edit_object_placement`` re-applies the opening's pre-merge world
+ matrix so the void doesn't drift when the two walls have different
+ placements — the regression a ``PlacementRelTo`` swap alone would
+ fail."""
+ matrix = np.eye(4)
+ matrix[:3, 3] = (2.5, 0.0, 0.0)
+ rel, opening, _ = _opening_rel(opening_id=42, placement_matrix=matrix)
+ wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
+ captured: list[dict] = []
+
+ _run_merge(
+ wall1,
+ wall2,
+ element1,
+ element2,
+ opening_matrices={42: matrix},
+ captured_edit_calls=captured,
+ )
+
+ edit_calls_for_opening = [call for call in captured if call["product"] is opening]
+ assert len(edit_calls_for_opening) == 1
+ np.testing.assert_allclose(edit_calls_for_opening[0]["matrix"], matrix, atol=1e-9)
+ assert edit_calls_for_opening[0]["should_transform_children"] is False
+
+
+def test_merge_rehosts_before_delete():
+ """Order matters: ``delete_ifc_object`` cascades through
+ ``element2.HasOpenings`` and would destroy the void if it ran before
+ the re-host. Assert the survivor was rebound before delete fires."""
+ matrix = np.eye(4)
+ rel, opening, _ = _opening_rel(opening_id=7, placement_matrix=matrix)
+ wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
+
+ delete_ifc_object = _run_merge(
+ wall1,
+ wall2,
+ element1,
+ element2,
+ opening_matrices={7: matrix},
+ captured_edit_calls=[],
+ )
+
+ assert rel.RelatingBuildingElement is element1
+ delete_ifc_object.assert_called_once_with(wall2)
+
+
+def test_merge_skips_non_path_connection_rels():
+ """``ConnectedTo`` / ``ConnectedFrom`` carry both
+ ``IfcRelConnectsPathElements`` (wall-wall joins) AND
+ ``IfcRelConnectsElements`` (slab underside clips). Only the path rels
+ expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
+ accessing those attributes on an element rel raises ``AttributeError``.
+ The migration loop must filter on the rel class so a wall with a slab
+ clip can still be merged."""
+ from bonsai.bim.module.model.wall import DumbWallJoiner
+
+ wall1, wall2, element1, element2 = _merge_inputs(has_openings=[])
+
+ path_rel = Mock(name="path_rel")
+ path_rel.is_a = lambda c: c == "IfcRelConnectsPathElements"
+ path_rel.RelatingElement = Mock(name="rel_relating")
+ path_rel.RelatedElement = Mock(name="rel_related")
+ path_rel.RelatingConnectionType = "ATSTART"
+ path_rel.RelatedConnectionType = "ATEND"
+
+ slab_rel = Mock(name="slab_rel")
+ slab_rel.is_a = lambda c: c == "IfcRelConnectsElements"
+ slab_rel.Description = "TOP"
+ # ``RelatedConnectionType`` is what the merge loop reads from
+ # ``ConnectedFrom``; the real ``IfcRelConnectsElements`` schema has
+ # no such attribute, so wire the stub to raise like ifcopenshell does.
+ type(slab_rel).RelatedConnectionType = property(
+ lambda self: (_ for _ in ()).throw(AttributeError("RelatedConnectionType"))
+ )
+ type(slab_rel).RelatingConnectionType = property(
+ lambda self: (_ for _ in ()).throw(AttributeError("RelatingConnectionType"))
+ )
+ element2.ConnectedFrom = [slab_rel, path_rel]
+
+ captured_disconnects = []
+ captured_connects = []
+
+ def fake_disconnect_path(*args, **kwargs):
+ captured_disconnects.append(kwargs)
+
+ def fake_connect_path(*args, **kwargs):
+ captured_connects.append(kwargs)
+
+ p1 = np.array([0.0, 0.0])
+ p2 = np.array([5.0, 0.0])
+ p3 = np.array([5.0, 0.0])
+ p4 = np.array([10.0, 0.0])
+
+ def fake_get_entity(obj):
+ return {wall1: element1, wall2: element2}[obj]
+
+ with (
+ patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
+ patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
+ patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
+ patch(
+ "bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
+ side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
+ ),
+ patch(
+ "bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
+ return_value=np.eye(4),
+ ),
+ patch(
+ "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_path",
+ side_effect=fake_disconnect_path,
+ ),
+ patch(
+ "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.connect_path",
+ side_effect=fake_connect_path,
+ ),
+ patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
+ patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object"),
+ patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
+ ):
+ # The bug pre-fix: the slab rel's ``RelatedConnectionType`` access
+ # raised AttributeError and crashed merge. With the filter, this
+ # call must complete cleanly.
+ DumbWallJoiner().merge(wall1, wall2)
+
+ assert len(captured_disconnects) == 1
+ assert len(captured_connects) == 1
+ assert captured_disconnects[0]["connection_type"] == "ATEND"
diff --git a/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py
new file mode 100644
index 0000000000..68a95782fd
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py
@@ -0,0 +1,64 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Pins the contract that the three dimension-mutating wall operators —
+``bim.change_extrusion_depth``, ``bim.change_extrusion_x_angle``,
+``bim.change_layer_length`` — re-prime ``BIMWallProperties`` from the
+post-mutation IFC at the end of ``_execute``.
+
+Without the resync, ``props.height`` / ``props.length`` / ``props.x_angle``
+stay at their pre-mutation values; gizmo icons that position from
+``props.height`` then sit at the old elevation even though the wall mesh
+shows the new one."""
+
+import inspect
+
+import pytest
+
+pytestmark = pytest.mark.wall
+
+
+def _execute_source(operator_cls):
+ return inspect.getsource(operator_cls._execute)
+
+
+def test_change_extrusion_depth_resyncs_wall_props():
+ """Height mutation must re-prime ``BIMWallProperties.height`` so
+ gizmo icons positioned from ``props.height`` track the post-mutation
+ wall top in the same redraw."""
+ from bonsai.bim.module.model.wall import ChangeExtrusionDepth
+
+ assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth)
+
+
+def test_change_extrusion_x_angle_resyncs_wall_props():
+ """Slope mutation must re-prime ``BIMWallProperties.x_angle`` so
+ slope-driven gizmo positions track the new angle."""
+ from bonsai.bim.module.model.wall import ChangeExtrusionXAngle
+
+ assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle)
+
+
+def test_change_layer_length_resyncs_wall_props():
+ """Length mutation must re-prime ``BIMWallProperties.length`` so
+ horizontal gizmo X positions track the new axis extent."""
+ from bonsai.bim.module.model.wall import ChangeLayerLength
+
+ assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength)
diff --git a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py
new file mode 100644
index 0000000000..5fff484943
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py
@@ -0,0 +1,213 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Behaviour tests for the wall-slab connection helpers on tool.Wall.
+
+Pins the rel-shape contract (IfcRelConnectsElements with Description=="TOP")
+the underside-extension feature creates, and the icon placement contract the
+new wall-slab connection gizmo group reads."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from mathutils import Matrix, Vector
+
+import bonsai.tool as tool
+
+pytestmark = pytest.mark.model
+
+
+def _rel(klass: str = "IfcRelConnectsElements", description: str = "TOP", relating=None, related=None):
+ rel = Mock()
+ rel.is_a = lambda c: c == klass
+ rel.Description = description
+ rel.RelatingElement = relating
+ rel.RelatedElement = related
+ return rel
+
+
+def _wall_with_rels(*rels) -> Mock:
+ wall = Mock()
+ wall.ConnectedFrom = list(rels)
+ return wall
+
+
+def _slab_with_rels(*rels) -> Mock:
+ slab = Mock()
+ slab.ConnectedTo = list(rels)
+ return slab
+
+
+# ---------------------------------------------------------------------------
+# iter_wall_slab_connections — yields (slab, rel) for TOP rels
+# ---------------------------------------------------------------------------
+
+
+def test_iter_wall_slab_connections_yields_top_rels():
+ slab_a = Mock(name="slab_a")
+ slab_b = Mock(name="slab_b")
+ wall = _wall_with_rels(
+ _rel(relating=slab_a),
+ _rel(relating=slab_b),
+ )
+
+ result = list(tool.Wall.iter_wall_slab_connections(wall))
+
+ assert result == [(slab_a, wall.ConnectedFrom[0]), (slab_b, wall.ConnectedFrom[1])]
+
+
+def test_iter_wall_slab_connections_skips_non_top_description():
+ """Only TOP-described rels count; BOTTOM / SIDE / arbitrary strings are
+ skipped so other RelConnectsElements semantics aren't confused with the
+ underside-extension contract."""
+ slab = Mock()
+ wall = _wall_with_rels(
+ _rel(description="BOTTOM", relating=slab),
+ _rel(description="TOP", relating=slab),
+ )
+
+ result = list(tool.Wall.iter_wall_slab_connections(wall))
+
+ assert len(result) == 1
+ assert result[0][0] is slab
+
+
+def test_iter_wall_slab_connections_skips_non_connectselements_rels():
+ """Path-connections to other walls show up on ConnectedFrom too — the
+ helper must filter on rel class, not just presence."""
+ slab = Mock()
+ wall = _wall_with_rels(
+ _rel(klass="IfcRelConnectsPathElements", relating=slab),
+ _rel(klass="IfcRelConnectsElements", relating=slab),
+ )
+
+ result = list(tool.Wall.iter_wall_slab_connections(wall))
+
+ assert len(result) == 1
+
+
+def test_iter_wall_slab_connections_handles_none_relating():
+ """A malformed rel with RelatingElement=None is skipped rather than
+ raising — defensive against partially-loaded IFC files."""
+ wall = _wall_with_rels(_rel(relating=None))
+
+ result = list(tool.Wall.iter_wall_slab_connections(wall))
+
+ assert result == []
+
+
+def test_iter_wall_slab_connections_empty_when_no_connectedfrom():
+ wall = Mock()
+ wall.ConnectedFrom = []
+
+ assert list(tool.Wall.iter_wall_slab_connections(wall)) == []
+
+
+# ---------------------------------------------------------------------------
+# iter_slab_wall_connections — mirror, walks slab.ConnectedTo
+# ---------------------------------------------------------------------------
+
+
+def test_iter_slab_wall_connections_yields_top_rels():
+ wall_a = Mock()
+ wall_b = Mock()
+ slab = _slab_with_rels(
+ _rel(related=wall_a),
+ _rel(related=wall_b),
+ )
+
+ result = list(tool.Wall.iter_slab_wall_connections(slab))
+
+ assert [w for w, _ in result] == [wall_a, wall_b]
+
+
+def test_iter_slab_wall_connections_skips_non_top():
+ wall = Mock()
+ slab = _slab_with_rels(
+ _rel(description="BOTTOM", related=wall),
+ _rel(description="TOP", related=wall),
+ )
+
+ result = list(tool.Wall.iter_slab_wall_connections(slab))
+
+ assert len(result) == 1
+
+
+# ---------------------------------------------------------------------------
+# find_wall_slab_rel — locate specific rel between wall + slab
+# ---------------------------------------------------------------------------
+
+
+def test_find_wall_slab_rel_returns_match():
+ slab_a = Mock(name="slab_a")
+ slab_b = Mock(name="slab_b")
+ rel_a = _rel(relating=slab_a)
+ rel_b = _rel(relating=slab_b)
+ wall = _wall_with_rels(rel_a, rel_b)
+
+ assert tool.Wall.find_wall_slab_rel(wall, slab_b) is rel_b
+
+
+def test_find_wall_slab_rel_returns_none_when_unconnected():
+ slab_a = Mock(name="slab_a")
+ other_slab = Mock(name="other_slab")
+ wall = _wall_with_rels(_rel(relating=slab_a))
+
+ assert tool.Wall.find_wall_slab_rel(wall, other_slab) is None
+
+
+# ---------------------------------------------------------------------------
+# wall_slab_connection_location_world — icon anchor point
+# ---------------------------------------------------------------------------
+
+
+def test_wall_slab_connection_location_perches_above_wall_top():
+ """Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh
+ bbox top in world space plus WALL_SLAB_CONNECTION_Z_CLEARANCE so the
+ icon sits above the extend-vertical / slope gizmo at the wall top."""
+ wall_obj = Mock()
+ wall_obj.matrix_world = Matrix.Identity(4)
+ wall_obj.bound_box = [
+ (-0.1, -0.1, 0.0),
+ (0.1, -0.1, 0.0),
+ (-0.1, 0.1, 0.0),
+ (0.1, 0.1, 0.0),
+ (-0.1, -0.1, 3.0),
+ (0.1, -0.1, 3.0),
+ (-0.1, 0.1, 3.0),
+ (0.1, 0.1, 3.0),
+ ]
+ slab_obj = Mock()
+
+ ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0)))
+ with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line):
+ loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
+
+ expected_z = 3.0 + tool.Wall.WALL_SLAB_CONNECTION_Z_CLEARANCE
+ assert loc == Vector((2.0, 0.0, expected_z))
+
+
+def test_wall_slab_connection_location_returns_none_for_axisless_wall():
+ """A wall without an IFC Axis representation has no reference line; the
+ helper returns None so callers can skip rather than guess a location."""
+ wall_obj = Mock()
+ slab_obj = Mock()
+ with patch.object(tool.Wall, "get_world_reference_line", return_value=None):
+ assert tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) is None
diff --git a/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py
new file mode 100644
index 0000000000..499ce2509f
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py
@@ -0,0 +1,67 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch:
+
+1. Side classification reads the opening's axis-projected midpoint, not
+ the filling's ``matrix_world.translation``. The filling origin is
+ flip-fragile — flipping rotates the filler 180° + translates so the
+ bbox stays visually in place, which would mis-classify a flipped door
+ centred over the cut.
+2. When the void straddles the cut and the filling moves to element2,
+ the void copy for element1 is taken from the ORIGINAL opening (whose
+ ``ObjectPlacement`` still references element1), not the rebound
+ ``new_opening`` (whose ``PlacementRelTo`` was swapped to element2)."""
+
+import inspect
+
+import pytest
+
+pytestmark = pytest.mark.wall
+
+
+def _split_source():
+ from bonsai.bim.module.model.wall import DumbWallJoiner
+
+ return inspect.getsource(DumbWallJoiner.split)
+
+
+def test_side_classification_uses_opening_midpoint_not_filling_origin():
+ """Side classification must read the opening's axis-projected
+ midpoint, not the filling's world translation — the latter shifts
+ under flipping and would mis-classify a flipped door centred over
+ the cut."""
+ source = _split_source()
+ assert "opening_midpoint" in source
+ assert "filling_obj.matrix_world.translation" not in source
+
+
+def test_void_copy_reads_from_original_opening_before_remove():
+ """When the filling moves to element2 and the void straddles the
+ cut, element1's pure-void copy must come from the original opening
+ BEFORE the cleanup that destroys it — the rebound ``new_opening``
+ references element2's frame and would shift the void to element1's
+ origin in element2's local coords."""
+ source = _split_source()
+ branch_start = source.index("if opening_midpoint > cut_percentage:")
+ branch = source[branch_start:]
+ add_idx = branch.index("_add_void_copy(element1, opening)")
+ remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)")
+ assert add_idx < remove_idx
diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py
index cd8371e1c3..6715fe8a94 100644
--- a/src/bonsai/test/core/bootstrap.py
+++ b/src/bonsai/test/core/bootstrap.py
@@ -60,6 +60,13 @@ def collector():
prophet.verify()
+@pytest.fixture
+def connection():
+ prophet = Prophecy(bonsai.core.tool.Connection)
+ yield prophet
+ prophet.verify()
+
+
@pytest.fixture
def context():
prophet = Prophecy(bonsai.core.tool.Context)
diff --git a/src/bonsai/test/core/test_connection.py b/src/bonsai/test/core/test_connection.py
new file mode 100644
index 0000000000..5b09bc4dfa
--- /dev/null
+++ b/src/bonsai/test/core/test_connection.py
@@ -0,0 +1,218 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Dispatch tests for ``core.connection.disconnect_rel``.
+
+The dispatch is the single source of truth for per-kind cleanup shared by the
+explicit ``bim.disconnect_elements`` operator and the implicit cascade in
+``tool.Geometry.delete_ifc_object``. Each kind has one test that pins which
+helpers must be called; the AST forward-compat guard in
+``test_connection_forward_compat.py`` then asserts the dispatch table covers
+every kind ``Connection.find_rels`` can emit.
+
+Uses ``unittest.mock`` directly (rather than the Prophecy fixtures) because
+the dispatch passes IFC rel entities with attribute access (``rel.RelatingElement``)
+that Prophecy's JSON call recorder can't serialize.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+import pytest
+
+import bonsai.core.connection as subject
+
+
+def _rel(relating="slab", related="wall"):
+ return SimpleNamespace(RelatingElement=relating, RelatedElement=related)
+
+
+def _ifc_with_objects(mapping):
+ ifc = Mock()
+ ifc.get_object.side_effect = lambda e: mapping.get(e)
+ ifc.run = Mock()
+ return ifc
+
+
+class TestDisconnectRelPath:
+ def test_removes_connection_and_recreates_both_walls(self):
+ ifc = _ifc_with_objects({"elem_a": "obj_a", "elem_b": "obj_b"})
+ geometry = Mock()
+ model = Mock()
+ connection = Mock()
+
+ with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
+ subject.disconnect_rel(
+ ifc, geometry, model, connection,
+ rel="rel", kind="path", elem="elem_a", partner="elem_b",
+ )
+
+ remove.assert_called_once_with(geometry, connection="rel")
+ model.recreate_wall.assert_any_call("elem_a", "obj_a")
+ model.recreate_wall.assert_any_call("elem_b", "obj_b")
+ assert model.recreate_wall.call_count == 2
+
+ def test_skip_elem_recreate_suppresses_elem_side(self):
+ """Cascade case: elem is being deleted — don't recreate it."""
+ ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
+ geometry = Mock()
+ model = Mock()
+ connection = Mock()
+
+ with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
+ subject.disconnect_rel(
+ ifc, geometry, model, connection,
+ rel="rel", kind="path", elem="elem", partner="partner",
+ skip_elem_recreate=True,
+ )
+
+ model.recreate_wall.assert_called_once_with("partner", "partner_obj")
+
+ def test_skip_partner_recreate_suppresses_partner_side(self):
+ ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
+ geometry = Mock()
+ model = Mock()
+ connection = Mock()
+
+ with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
+ subject.disconnect_rel(
+ ifc, geometry, model, connection,
+ rel="rel", kind="path", elem="elem", partner="partner",
+ skip_partner_recreate=True,
+ )
+
+ model.recreate_wall.assert_called_once_with("elem", "elem_obj")
+
+ def test_both_skips_means_only_remove_rel(self):
+ ifc = _ifc_with_objects({})
+ geometry = Mock()
+ model = Mock()
+ connection = Mock()
+
+ with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
+ subject.disconnect_rel(
+ ifc, geometry, model, connection,
+ rel="rel", kind="path", elem="elem", partner="partner",
+ skip_elem_recreate=True,
+ skip_partner_recreate=True,
+ )
+
+ remove.assert_called_once()
+ model.recreate_wall.assert_not_called()
+
+
+class TestDisconnectRelElementTop:
+ def test_disconnects_then_regenerates_wall(self):
+ """Operator case (no skip flags): both sides survive, so the wall gets
+ re-clipped against currently-connected slabs."""
+ rel = _rel()
+ ifc = _ifc_with_objects({"wall": "wall_obj"})
+ geometry = Mock()
+ model = Mock()
+ connection = Mock()
+ connection.orient_element_top.return_value = ("wall", "slab")
+
+ with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
+ subject.disconnect_rel(
+ ifc, geometry, model, connection,
+ rel=rel, kind="element-top", elem="elem", partner="partner",
+ )
+
+ ifc.run.assert_called_once_with(
+ "geometry.disconnect_element", relating_element="slab", related_element="wall"
+ )
+ regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"])
+
+ def test_slab_delete_cascade_still_regenerates_wall(self):
+ """When slab is being deleted (elem=slab), wall survives and must
+ re-clip against remaining connections — the cascade's main purpose."""
+ rel = _rel()
+ ifc = _ifc_with_objects({"wall": "wall_obj"})
+ connection = Mock()
+ connection.orient_element_top.return_value = ("wall", "slab")
+
+ with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
+ subject.disconnect_rel(
+ ifc, Mock(), Mock(), connection,
+ rel=rel, kind="element-top", elem="slab", partner="wall",
+ skip_elem_recreate=True, # slab is being deleted
+ )
+
+ regen.assert_called_once()
+
+ def test_wall_delete_cascade_skips_wall_regen(self):
+ """When the wall itself is being deleted, regenerating its body moments
+ before remove_product wipes it is wasted work — skip."""
+ rel = _rel()
+ ifc = _ifc_with_objects({"wall": "wall_obj"})
+ connection = Mock()
+ connection.orient_element_top.return_value = ("wall", "slab")
+
+ with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
+ subject.disconnect_rel(
+ ifc, Mock(), Mock(), connection,
+ rel=rel, kind="element-top", elem="wall", partner="slab",
+ skip_elem_recreate=True, # wall is being deleted
+ )
+
+ regen.assert_not_called()
+ ifc.run.assert_called_once() # rel still removed
+
+ def test_both_in_batch_skips_wall_regen(self):
+ """Batch delete of both endpoints, processing slab first: partner (wall)
+ also queued for deletion → skip wall regen."""
+ rel = _rel()
+ ifc = _ifc_with_objects({"wall": "wall_obj"})
+ connection = Mock()
+ connection.orient_element_top.return_value = ("wall", "slab")
+
+ with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
+ subject.disconnect_rel(
+ ifc, Mock(), Mock(), connection,
+ rel=rel, kind="element-top", elem="slab", partner="wall",
+ skip_elem_recreate=True,
+ skip_partner_recreate=True, # wall also in batch
+ )
+
+ regen.assert_not_called()
+
+
+class TestDisconnectRelElement:
+ def test_just_removes_the_rel(self):
+ rel = _rel(relating="A", related="B")
+ ifc = Mock()
+
+ subject.disconnect_rel(
+ ifc, Mock(), Mock(), Mock(),
+ rel=rel, kind="element", elem="elem_a", partner="elem_b",
+ )
+
+ ifc.run.assert_called_once_with(
+ "geometry.disconnect_element", relating_element="A", related_element="B"
+ )
+
+
+class TestDisconnectRelUnknownKind:
+ def test_raises_value_error(self):
+ with pytest.raises(ValueError, match="Unknown rel kind"):
+ subject.disconnect_rel(
+ Mock(), Mock(), Mock(), Mock(),
+ rel="rel", kind="bogus", elem="a", partner="b",
+ )
diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py
index 7a2f8017d3..e532bacc02 100644
--- a/src/bonsai/test/tool/test_blender.py
+++ b/src/bonsai/test/tool/test_blender.py
@@ -183,3 +183,16 @@ class TestNpFrombufferLegacy(NewFile):
result = subject.np_frombuffer_legacy(data, n)
assert result.shape == (n,)
np.testing.assert_allclose(result, np.arange(n))
+
+
+class TestGetObjectFromGuidMissing(NewFile):
+ """``get_object_from_guid`` must honour its ``Optional[Object]`` return
+ contract: a GUID that does not resolve in the current IFC file yields
+ ``None``, not a ``RuntimeError``. Callers iterate stored GUID lists
+ (array children, library refs, …) and rely on the falsy return to
+ skip stale entries."""
+
+ def test_returns_none_when_guid_not_in_file(self):
+ bpy.ops.bim.create_project()
+ assert tool.Ifc.get() is not None
+ assert subject.get_object_from_guid("3iyt7r$Hf4_hQYNhBIDJI4") is None
diff --git a/src/bonsai/test/tool/test_cad.py b/src/bonsai/test/tool/test_cad.py
index 2e84c71718..51491e259d 100644
--- a/src/bonsai/test/tool/test_cad.py
+++ b/src/bonsai/test/tool/test_cad.py
@@ -16,7 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
-from mathutils import Vector
+import math
+
+from mathutils import Matrix, Vector
from bonsai.tool.cad import Cad as subject
from test.bim.bootstrap import NewFile
@@ -88,3 +90,87 @@ class TestClosestPoints(NewFile):
edge1 = (V(0, 0, 0), V(0, 0, 0))
edge2 = (V(1, 0, 1), V(2, 0, 2))
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0])
+
+
+class TestObbWorldClipPlanes(NewFile):
+ def test_unit_box_at_origin_returns_axis_aligned_planes(self):
+ planes = subject.obb_world_clip_planes(
+ V(0, 0, 0),
+ (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
+ V(1, 1, 1),
+ )
+ assert planes[0] == (-1.0, 0.0, 0.0, 1.0)
+ assert planes[1] == (1.0, 0.0, 0.0, 1.0)
+ assert planes[2] == (0.0, -1.0, 0.0, 1.0)
+ assert planes[3] == (0.0, 1.0, 0.0, 1.0)
+ assert planes[4] == (0.0, 0.0, -1.0, 1.0)
+ assert planes[5] == (0.0, 0.0, 1.0, 1.0)
+
+ def test_center_is_inside_all_planes(self):
+ center = V(5, -3, 2)
+ planes = subject.obb_world_clip_planes(
+ center,
+ (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
+ V(2, 1, 0.5),
+ )
+ assert subject.point_is_inside_clip_planes(planes, center)
+
+ def test_point_just_outside_positive_x_face_rejected(self):
+ planes = subject.obb_world_clip_planes(
+ V(0, 0, 0),
+ (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
+ V(1, 1, 1),
+ )
+ assert subject.point_is_inside_clip_planes(planes, V(0.5, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(1.5, 0, 0))
+
+ def test_rotated_obb_clips_along_rotated_axes(self):
+ s = math.sin(math.radians(45))
+ planes = subject.obb_world_clip_planes(
+ V(0, 0, 0),
+ (V(s, s, 0), V(-s, s, 0), V(0, 0, 1)),
+ V(1, 1, 1),
+ )
+ assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
+
+ def test_zero_extent_axis_does_not_raise(self):
+ planes = subject.obb_world_clip_planes(
+ V(0, 0, 0),
+ (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
+ V(1, 1, 0),
+ )
+ assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
+
+
+class TestObbClipPlanesFromMatrix(NewFile):
+ def test_identity_matches_unit_box(self):
+ planes = subject.obb_clip_planes_from_matrix(Matrix.Identity(4))
+ assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(2, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(0, -2, 0))
+
+ def test_translated_host_shifts_clip_region(self):
+ translated = Matrix.Translation(V(10, 0, 0))
+ planes = subject.obb_clip_planes_from_matrix(translated)
+ assert not subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
+ assert subject.point_is_inside_clip_planes(planes, V(10, 0, 0))
+
+ def test_z_rotation_rotates_box(self):
+ rot = Matrix.Rotation(math.radians(45), 4, "Z")
+ planes = subject.obb_clip_planes_from_matrix(rot)
+ assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
+
+ def test_host_scale_scales_box_extents(self):
+ scaled = Matrix.Diagonal((2.0, 2.0, 2.0, 1.0))
+ planes = subject.obb_clip_planes_from_matrix(scaled)
+ assert subject.point_is_inside_clip_planes(planes, V(1.9, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(2.1, 0, 0))
+
+ def test_non_uniform_scale_axis_independent(self):
+ scaled = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
+ planes = subject.obb_clip_planes_from_matrix(scaled)
+ assert subject.point_is_inside_clip_planes(planes, V(2.9, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(3.1, 0, 0))
+ assert not subject.point_is_inside_clip_planes(planes, V(0, 1.1, 0))
diff --git a/src/bonsai/test/tool/test_connection_forward_compat.py b/src/bonsai/test/tool/test_connection_forward_compat.py
new file mode 100644
index 0000000000..af830353b3
--- /dev/null
+++ b/src/bonsai/test/tool/test_connection_forward_compat.py
@@ -0,0 +1,123 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a
+branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups.
+
+Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to
+``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel``
+would silently regress the disconnect operator and the cascade-on-delete: a new
+kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")``
+fallback, and either crash the operator or leave the cascade half-done. This
+guard makes the symmetry mandatory at test time."""
+
+import ast
+from pathlib import Path
+
+import pytest
+
+pytestmark = pytest.mark.model
+
+
+BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
+TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py"
+CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py"
+
+
+def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef:
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef) and node.name == name:
+ return node
+ raise AssertionError(f"Function {name!r} not found")
+
+
+def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef:
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
+ for child in node.body:
+ if isinstance(child, ast.FunctionDef) and child.name == method_name:
+ return child
+ raise AssertionError(f"Method {class_name}.{method_name} not found")
+
+
+def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]:
+ """Extract every kind label this method emits.
+
+ Looks at exactly two narrow patterns to avoid false positives from
+ docstrings or type-annotation strings:
+
+ - ``_record(rel, "", …)`` — positional string at index 1, the
+ conventional emit shape in ``find_rels`` / ``find_rels_for_element``.
+ - ``kind = "" if … else ""`` and chained variants — string
+ literals on either branch of an ``ast.IfExp`` assigned to ``kind``.
+ """
+ kinds: set[str] = set()
+ for node in ast.walk(method):
+ if isinstance(node, ast.Call):
+ func = node.func
+ if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2:
+ arg = node.args[1]
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
+ kinds.add(arg.value)
+ elif isinstance(arg, ast.IfExp):
+ for branch in (arg.body, arg.orelse):
+ if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
+ kinds.add(branch.value)
+ elif isinstance(node, ast.Assign):
+ targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"]
+ if not targets or not isinstance(node.value, ast.IfExp):
+ continue
+ for branch in (node.value.body, node.value.orelse):
+ if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
+ kinds.add(branch.value)
+ return kinds
+
+
+def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]:
+ """Return every kind matched by ``disconnect_rel``'s ``kind == "…"`` branches."""
+ fn = _find_function(tree, "disconnect_rel")
+ kinds: set[str] = set()
+ for node in ast.walk(fn):
+ if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq):
+ left = node.left
+ right = node.comparators[0]
+ if isinstance(left, ast.Name) and left.id == "kind":
+ if isinstance(right, ast.Constant) and isinstance(right.value, str):
+ kinds.add(right.value)
+ return kinds
+
+
+def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None:
+ tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8"))
+ core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8"))
+
+ emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by(
+ _find_method(tool_tree, "Connection", "find_rels_for_element")
+ )
+ handled = _kind_branches_in_disconnect_rel(core_tree)
+
+ assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed"
+
+ missing = emitted - handled
+ assert not missing, (
+ f"core.connection.disconnect_rel is missing branches for kinds {missing}. "
+ f"Every kind returned by Connection.find_rels / find_rels_for_element "
+ f"must have a matching if/elif branch in the dispatch."
+ )
diff --git a/src/bonsai/test/tool/test_geometry.py b/src/bonsai/test/tool/test_geometry.py
index 424e1fd876..5683ce03de 100644
--- a/src/bonsai/test/tool/test_geometry.py
+++ b/src/bonsai/test/tool/test_geometry.py
@@ -135,6 +135,37 @@ class TestGetRepresentationData(NewFile):
assert subject.get_representation_data(representation) == data
+class TestGetActiveRepresentation(NewFile):
+ def test_returns_representation_for_live_id(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ representation = ifc.createIfcShapeRepresentation()
+ mesh = bpy.data.meshes.new("Mesh")
+ obj = bpy.data.objects.new("Object", mesh)
+ tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id()
+ assert subject.get_active_representation(obj) == representation
+
+ def test_returns_none_when_mesh_has_no_id(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
+ assert subject.get_active_representation(obj) is None
+
+ def test_returns_none_when_id_is_stale(self):
+ """A representation rebuild can free the old entity while obj.data
+ still tracks its id. Returning ``None`` keeps every UI redraw alive
+ instead of spamming ``RuntimeError`` from the by_id lookup."""
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ representation = ifc.createIfcShapeRepresentation()
+ mesh = bpy.data.meshes.new("Mesh")
+ obj = bpy.data.objects.new("Object", mesh)
+ stale_id = representation.id()
+ tool.Geometry.get_mesh_props(mesh).ifc_definition_id = stale_id
+ ifc.remove(representation)
+ assert subject.get_active_representation(obj) is None
+
+
class TestGetRepresentationId(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py
index fd9e3dfad8..9d21aedc1a 100644
--- a/src/bonsai/test/tool/test_model.py
+++ b/src/bonsai/test/tool/test_model.py
@@ -672,6 +672,30 @@ class TestUsingArrays(NewFile):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
assert pset is None, (obj, pset)
+ def test_remove_array_tolerates_stale_child_guid(self):
+ """``bim.remove_array`` and the underlying ``regenerate_array`` must
+ survive a child GUID in ``BBIM_Array.Data`` that no longer resolves
+ in the file. Real-world IFC files can carry dangling array refs
+ from external edits — the remove path is meant to delete those
+ children, so an already-missing entity is the desired terminal
+ state, not a fatal error."""
+ self.setup_array()
+ parent_obj = bpy.context.active_object
+ parent_element = tool.Ifc.get_entity(parent_obj)
+ ifc_file = tool.Ifc.get()
+
+ pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
+ data = json.loads(pset["Data"])
+ data[0]["children"].append("3iyt7r$Hf4_hQYNhBIDJI4")
+ ifcopenshell.api.pset.edit_pset(
+ ifc_file,
+ pset=ifc_file.by_id(pset["id"]),
+ properties={"Data": json.dumps(data)},
+ )
+
+ bpy.ops.bim.remove_array(item=0)
+ assert ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") is None
+
class TestApplyIfcMaterialChanges(NewFile):
def get_used_styles(self, obj: bpy.types.Object) -> set[ifcopenshell.entity_instance]:
diff --git a/src/examples/IfcParseExamples.cpp b/src/examples/IfcParseExamples.cpp
index 8af17f7717..2951285a60 100644
--- a/src/examples/IfcParseExamples.cpp
+++ b/src/examples/IfcParseExamples.cpp
@@ -25,8 +25,6 @@
#include "ifcparse/IfcFile.h"
#include "ifcparse/IfcLogger.h"
-#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
-#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include
#include
@@ -43,6 +41,30 @@
static_assert(false, "A boost preprocessor sequence of schema identifiers is needed for this file to compile.");
#endif
+// A macro cannot expand to an include directive, so unroll enough includes for
+// the maximum number of schemas supported by the build configuration.
+#define INCLUDE_SCHEMA_N(n) \
+ BOOST_PP_IIF(BOOST_PP_GREATER(BOOST_PP_SEQ_SIZE(SCHEMA_SEQ), n), \
+ BOOST_PP_STRINGIZE(ifcparse/BOOST_PP_CAT(Ifc, BOOST_PP_SEQ_ELEM(BOOST_PP_MIN(n, BOOST_PP_SEQ_SIZE(BOOST_PP_SEQ_POP_BACK(SCHEMA_SEQ))), SCHEMA_SEQ)).h), \
+ "ifcgeom/empty.h")
+
+#include INCLUDE_SCHEMA_N(0)
+#include INCLUDE_SCHEMA_N(1)
+#include INCLUDE_SCHEMA_N(2)
+#include INCLUDE_SCHEMA_N(3)
+#include INCLUDE_SCHEMA_N(4)
+#include INCLUDE_SCHEMA_N(5)
+#include INCLUDE_SCHEMA_N(6)
+#include INCLUDE_SCHEMA_N(7)
+#include INCLUDE_SCHEMA_N(8)
+#include INCLUDE_SCHEMA_N(9)
+#include INCLUDE_SCHEMA_N(10)
+#include INCLUDE_SCHEMA_N(11)
+#include INCLUDE_SCHEMA_N(12)
+#include INCLUDE_SCHEMA_N(13)
+#include INCLUDE_SCHEMA_N(14)
+#include INCLUDE_SCHEMA_N(15)
+
#include
#if USE_VLD
@@ -58,9 +80,9 @@ struct is_ifc4_or_higher> : s
typedef std::map> element_properties;
#ifdef SCHEMA_HAS_IfcBuildingElement
-typedef IfcSchema::IfcBuildingElement element_t
+typedef IfcSchema::IfcBuildingElement element_t;
#else
-typedef IfcSchema::IfcBuiltElement element_t
+typedef IfcSchema::IfcBuiltElement element_t;
#endif
std::string format_string(const AttributeValue& argument) {
@@ -242,7 +264,7 @@ int main(int argc, char** argv) {
// we need to cast them to IfcWindows. Since these properties
// are optional we need to make sure the properties are
// defined for the window in question before accessing them.
- example_element_type::list::ptr elements = file.instances_by_type();
+ auto elements = file.instances_by_type();
std::cout << "Found " << elements->size() << " elements in " << argv[1] << ":" << std::endl;
diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py
index 5aab765c26..a57e52426c 100644
--- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py
+++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py
@@ -21,6 +21,7 @@ from __future__ import annotations
import argparse
import datetime
+import json
import logging
import os
import time
@@ -256,26 +257,31 @@ class IfcDataGetter:
return ""
if cost_item.CostQuantities is None:
return ""
- string = "["
+ result = []
for quantity in cost_item.CostQuantities:
- string += '["'
+ prefix = ""
for rel in file.get_inverse(quantity):
if rel.is_a("IfcPropertySet") or rel.is_a("IfcElementQuantity"):
- prop_set = rel
# Find elements that have this property set
- for prop_rel in file.get_inverse(prop_set):
+ for prop_rel in file.get_inverse(rel):
if prop_rel.is_a("IfcRelDefinesByProperties"):
for obj in prop_rel.RelatedObjects:
if obj.is_a("IfcElement"):
- string += obj.Name + " - "
- string += quantity.Name
+ prefix += (obj.Name or "") + " - "
+ name = prefix + (quantity.Name or "")
+ # Formula is an optional IfcLabel on IfcQuantity* in IFC4+; absent in
+ # IFC2X3, hence the schema-safe getattr.
+ formula = getattr(quantity, "Formula", None) or ""
if quantity.is_a("IfcPhysicalSimpleQuantity"):
- string += '", ' + str(quantity[3]) + "],"
+ value = quantity[3]
+ try:
+ value = float(value) if value is not None else 0.0
+ except (TypeError, ValueError):
+ value = 0.0
+ result.append([name, value, formula])
else:
- string += ' ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0],'
- string = string.removesuffix(",")
- string += "]"
- return string
+ result.append([name + " ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, formula])
+ return json.dumps(result, ensure_ascii=False)
class SheetData(TypedDict):
diff --git a/src/ifc5d/test/test_csv2ifc.py b/src/ifc5d/test/test_csv2ifc.py
index c14a795da3..01b4a85ee6 100644
--- a/src/ifc5d/test/test_csv2ifc.py
+++ b/src/ifc5d/test/test_csv2ifc.py
@@ -17,6 +17,7 @@
# along with IfcOpenShell. If not, see .
import csv
+import json
import tempfile
from pathlib import Path
@@ -118,3 +119,44 @@ class TestCsv2Ifc:
writer.write()
assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1
assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1
+
+
+class TestSerialiseCostQuantities:
+ def test_quantity_name_with_special_characters_round_trips_as_json(self):
+ ifc_file = ifcopenshell.file()
+ name = 'Prospetto est "Np=256,667-23"'
+ quantity = ifc_file.create_entity("IfcQuantityArea", Name=name, AreaValue=12.5)
+ cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
+
+ result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
+
+ assert json.loads(result) == [[name, 12.5, ""]]
+
+ def test_unset_name_does_not_crash(self):
+ ifc_file = ifcopenshell.file()
+ # Name left unset so quantity.Name resolves to None at access time.
+ quantity = ifc_file.create_entity("IfcQuantityArea", AreaValue=3.0)
+ cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
+
+ result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
+
+ assert json.loads(result) == [["", 3.0, ""]]
+
+ def test_formula_is_included_when_present(self):
+ ifc_file = ifcopenshell.file()
+ quantity = ifc_file.create_entity("IfcQuantityArea", Name="Area", AreaValue=12.5, Formula="Length * Width")
+ cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
+
+ result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
+
+ assert json.loads(result) == [["Area", 12.5, "Length * Width"]]
+
+ def test_quantity_without_formula_attribute_does_not_crash(self):
+ # IfcPhysicalComplexQuantity has no Formula attribute and is unsupported.
+ ifc_file = ifcopenshell.file()
+ quantity = ifc_file.create_entity("IfcPhysicalComplexQuantity", Name="Complex", Discrimination="layer")
+ cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
+
+ result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
+
+ assert json.loads(result) == [["Complex ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, ""]]
diff --git a/src/ifcgeom/abstract_mapping.cpp b/src/ifcgeom/abstract_mapping.cpp
index 44cbd7d0b5..350a6df02a 100644
--- a/src/ifcgeom/abstract_mapping.cpp
+++ b/src/ifcgeom/abstract_mapping.cpp
@@ -45,6 +45,11 @@ ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingF
throw IfcParse::IfcException("No geometry mapping registered for " + schema_name_lower);
}
auto new_mapping = it->second(file, s, logger);
- new_mapping->initialize_settings();
+ try {
+ new_mapping->initialize_settings();
+ } catch (const std::exception& e) {
+ logger.Error("GEO", 400, e);
+ logger.Error("GEO", 401, "Unable to initialize conversion settings");
+ }
return new_mapping;
}
diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile
index 7d6592635d..350808ec25 100644
--- a/src/ifcopenshell-python/Makefile
+++ b/src/ifcopenshell-python/Makefile
@@ -54,8 +54,8 @@ ifeq ($(PLATFORM), win64)
PLATFORMTAG:=win_amd64
endif
-BINARY_VERSION:=0.8.5
-BUILD_COMMIT:=1c5b825
+BINARY_VERSION:=0.8.6
+BUILD_COMMIT:=3e7b739
IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py
index 2c93e8175e..85b310b9ce 100644
--- a/src/ifcopenshell-python/ifcopenshell/__init__.py
+++ b/src/ifcopenshell-python/ifcopenshell/__init__.py
@@ -95,7 +95,9 @@ from .entity_instance import entity_instance, register_schema_attributes
from .file import file, rocksdb_lazy_instance
from .file import file as _file
from .sql import sqlite, sqlite_entity
-from .ifcopenshell_wrapper import get_log, logger
+
+get_log = ifcopenshell_wrapper.get_log
+logger = getattr(ifcopenshell_wrapper, "logger", None)
# explicitly specify available imported symbols
# (it's a requirement for a typed library)
@@ -132,10 +134,20 @@ class SchemaError(Error):
@overload
def open(
- path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[False] = False
+ path: Union[os.PathLike, str],
+ format: SupportedFormat = None,
+ *,
+ should_stream: Literal[False] = False,
+ logger: Optional[logger] = None,
) -> Union[_file, sqlite]: ...
@overload
-def open(path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[True]) -> _stream: ...
+def open(
+ path: Union[os.PathLike, str],
+ format: SupportedFormat = None,
+ *,
+ should_stream: Literal[True],
+ logger: Optional[logger] = None,
+) -> _stream: ...
@overload
def open(
path: Union[os.PathLike, str],
@@ -143,6 +155,7 @@ def open(
*,
should_stream: bool = False,
readonly: bool = False,
+ logger: Optional[logger] = None,
) -> Union[_file, sqlite, _stream]: ...
def open(
path: Union[os.PathLike, str],
@@ -151,11 +164,13 @@ def open(
readonly: bool = False,
mmap: bool = False,
bypass_types: Optional[Sequence[str]] = None,
+ logger: Optional[logger] = None,
) -> Union[_file, sqlite, _stream]:
"""Loads an IFC dataset from a filepath
:param should_stream: Whether to open the file in streaming mode. Could be useful
for reading large files.
+ :param logger: Logger that receives native parser messages.
You can specify a file format. If no format is given, it is guessed from
its extension.
@@ -179,8 +194,10 @@ def open(
raise FileNotFoundError(f"Path does not exist: '{path}'.")
if format is None:
format = guess_format(path)
+ if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)):
+ logger = logger_type.Root()
if format == ".ifcXML":
- f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()))
+ f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *((logger,) if logger is not None else ()))
if f:
return file(f)
raise OSError(f"Failed to parse .ifcXML file from {path}")
@@ -189,7 +206,7 @@ def open(
with zipfile.ZipFile(path) as zf:
for name in zf.namelist():
if Path(name).suffix.lower() in (".ifc", ".ifcxml"):
- return open(zf.extract(name, unzipped_path))
+ return open(zf.extract(name, unzipped_path), logger=logger)
else:
raise LookupError(f"No .ifc or .ifcXML file found in {path}")
if format == ".ifcSQLite":
@@ -197,9 +214,11 @@ def open(
if should_stream:
return stream(path)
if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux.
- f = ifcopenshell_wrapper.open(str(path.absolute()), readonly=readonly)
+ f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *((logger,) if logger is not None else ()))
elif bypass_types:
- f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag())
+ f = ifcopenshell_wrapper.file(
+ ifcopenshell_wrapper.uninitialized_tag(), *((logger,) if logger is not None else ())
+ )
for ty in bypass_types:
f.bypass_type(ty)
if mmap:
@@ -209,9 +228,12 @@ def open(
f.initialize(str(path.absolute()))
elif mmap:
# mmap parameter is only available for builds with USE_MMAP, not used in our main builds
- f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument]
+ kwargs = {"mmap": mmap}
+ if logger is not None:
+ kwargs["logger"] = logger
+ f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument]
else:
- f = ifcopenshell_wrapper.open(str(path.absolute()))
+ f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ()))
return file(f)
diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py
index f4ea932933..147a23498d 100644
--- a/src/ifcopenshell-python/ifcopenshell/draw.py
+++ b/src/ifcopenshell-python/ifcopenshell/draw.py
@@ -105,7 +105,10 @@ def main(
iterators: Sequence[ifcopenshell.geom.iterator] = (),
merge_projection: bool = True,
progress_function: Callable = DO_NOTHING,
+ logger=None,
):
+ if logger is None and ifcopenshell.logger is not None:
+ logger = ifcopenshell.logger.Root()
def by_guid(g):
for f in files:
@@ -147,7 +150,7 @@ def main(
iterator_kwargs["include"] = list(
filter(has_selected_parent, sum((f.by_type(x) for x in iterator_kwargs["include"]), []))
)
- return ifcopenshell.geom.iterator(geom_settings, f, **iterator_kwargs)
+ return ifcopenshell.geom.iterator(geom_settings, f, logger=logger, **iterator_kwargs)
# We have to keep the iterator in memory because otherwise
# the styles are cleared up.
@@ -458,7 +461,6 @@ def main(
g1.appendChild(g2)
if settings.arrange_spaces or settings.arrange_zones:
-
if settings.storey_filter:
# delete storey groups not selected by filter
# sometimes happens in case of elements protruding multiple stories
@@ -541,6 +543,7 @@ def main(
arranged = W.arrange_polygons(
*filter(None, (ARRANGE_POLYGON_SETTINGS,)),
polies, # ty: ignore[too-many-positional-arguments]
+ *((logger,) if logger is not None else ()),
)
svg_data_3 = W.polygons_to_svg(arranged, False)
dom3 = parseString(svg_data_3)
diff --git a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py
index ef3c3c3ef0..578e561e79 100644
--- a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py
+++ b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py
@@ -217,7 +217,8 @@ for id in to_emit:
statements.append("%s << %s" % (id, stmt))
if __name__ == "__main__":
- print(r"""
+ print(
+ r"""
# This file is generated by IfcOpenShell ifcexpressparser bootstrap.py
from __future__ import annotations
@@ -256,4 +257,6 @@ if __name__ == "__main__":
mdl = importlib.import_module(output)
mdl.Generator(m).emit()
sys.stdout.write(m.schema.name)
-""" % ("\n ".join(statements)))
+"""
+ % ("\n ".join(statements))
+ )
diff --git a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py
index 3981dbc421..dd1e96c889 100644
--- a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py
+++ b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py
@@ -363,18 +363,24 @@ class EarlyBoundCodeWriter:
)
)
- self.statements[self.statements.index("{factory_placeholder}")] = """
+ self.statements[self.statements.index("{factory_placeholder}")] = (
+ """
class %(schema_name)s_instance_factory : public IfcParse::instance_factory {
virtual IfcUtil::IfcBaseClass* operator()(const IfcParse::declaration* decl, IfcEntityInstanceData&& data) const {
%(instance_mapping)s
}
};
-""" % locals()
+"""
+ % locals()
+ )
""
- self.statements[self.statements.index("{string_pool_placeholder}")] = """
+ self.statements[self.statements.index("{string_pool_placeholder}")] = (
+ """
const std::string strings[] = {%s};
-""" % ",".join(map(lambda s: '"%s"s' % s, self.strings))
+"""
+ % ",".join(map(lambda s: '"%s"s' % s, self.strings))
+ )
def __str__(self):
return "\n".join(self.statements)
diff --git a/src/ifcopenshell-python/ifcopenshell/geom/app.py b/src/ifcopenshell-python/ifcopenshell/geom/app.py
index d6bab207f1..fa07f3f2b8 100644
--- a/src/ifcopenshell-python/ifcopenshell/geom/app.py
+++ b/src/ifcopenshell-python/ifcopenshell/geom/app.py
@@ -145,7 +145,8 @@ class configuration:
config.set(
"snippets",
"print all wall ids",
- self.config_encode("""
+ self.config_encode(
+ """
###########################################################################
# A simple script that iterates over all walls in the current model #
# and prints their Globally unique IDs (GUIDS) to the console window #
@@ -153,13 +154,15 @@ class configuration:
for wall in model.by_type("IfcWall"):
print ("wall with global id: "+str(wall.GlobalId))
-""".lstrip()),
+""".lstrip()
+ ),
)
config.set(
"snippets",
"print properties of current selection",
- self.config_encode("""
+ self.config_encode(
+ """
###########################################################################
# A simple script that iterates over all IfcPropertySets of the currently #
# selected object and prints them to the console #
@@ -177,7 +180,8 @@ if selection:
for prop in relDefinesByProperties.RelatingPropertyDefinition.HasProperties:
print ("{:<20} :{}".format(prop.Name,prop.NominalValue.wrappedValue))
print ("\\n")
-""".lstrip()),
+""".lstrip()
+ ),
)
with open(conf_file, "w") as configfile:
config.write(configfile)
diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py
index 574d19d904..fc66264b95 100644
--- a/src/ifcopenshell-python/ifcopenshell/geom/main.py
+++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py
@@ -299,13 +299,16 @@ class iterator(ifcopenshell_wrapper.Iterator):
include: Optional[Union[list[entity_instance], list[str]]] = None,
exclude: Optional[Union[list[entity_instance], list[str]]] = None,
geometry_library: GEOMETRY_LIBRARY = "opencascade",
+ logger=None,
):
self.settings = settings
+ if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)):
+ logger = logger_type.Root()
if isinstance(file_or_filename, file):
self.file = file
file_or_filename = file_or_filename.wrapped_data
else:
- file_or_filename = self.file = open(file_or_filename)
+ file_or_filename = self.file = open(file_or_filename, logger=logger)
if include is not None and exclude is not None:
raise ValueError("include and exclude cannot be specified simultaneously")
@@ -333,13 +336,18 @@ class iterator(ifcopenshell_wrapper.Iterator):
else:
initializer = ifcopenshell_wrapper.construct_iterator_with_include_exclude
- self.this = initializer(
- geometry_library, self.settings, file_or_filename, include_or_exclude, include is not None, num_threads
+ args = (
+ geometry_library,
+ self.settings,
+ file_or_filename,
+ include_or_exclude,
+ include is not None,
+ num_threads,
)
+ self.this = initializer(*args, *((logger,) if logger is not None else ()))
else:
- self.this = ifcopenshell_wrapper.construct_iterator(
- geometry_library, self.settings, file_or_filename, num_threads
- )
+ args = (geometry_library, self.settings, file_or_filename, num_threads)
+ self.this = ifcopenshell_wrapper.construct_iterator(*args, *((logger,) if logger is not None else ()))
if has_occ:
@@ -508,7 +516,11 @@ def create_shape(
return wrap_shape_creation(
settings,
ifcopenshell_wrapper.create_shape(
- settings, inst.wrapped_data, repr.wrapped_data if repr is not None else None, geometry_library, *(filter(None, (logger,)))
+ settings,
+ inst.wrapped_data,
+ repr.wrapped_data if repr is not None else None,
+ geometry_library,
+ *((logger,) if logger is not None else ()),
),
)
@@ -564,6 +576,7 @@ def iterate(
cache: Optional[str] = None,
serializer_settings: Optional[serializer_settings] = None,
geometry_library: GEOMETRY_LIBRARY = "opencascade",
+ logger=None,
) -> Generator[IteratorOutput, None, None]: ...
@overload
def iterate(
@@ -577,6 +590,7 @@ def iterate(
cache: Optional[str] = None,
serializer_settings: Optional[serializer_settings] = None,
geometry_library: GEOMETRY_LIBRARY = "opencascade",
+ logger=None,
) -> Generator[tuple[int, IteratorOutput], None, None]: ...
@overload
def iterate(
@@ -590,6 +604,7 @@ def iterate(
cache: Optional[str] = None,
serializer_settings: Optional[serializer_settings] = None,
geometry_library: GEOMETRY_LIBRARY = "opencascade",
+ logger=None,
) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: ...
def iterate(
settings: settings,
@@ -602,13 +617,14 @@ def iterate(
cache: Optional[str] = None,
serializer_settings: Optional[serializer_settings] = None,
geometry_library: GEOMETRY_LIBRARY = "opencascade",
+ logger=None,
) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]:
"""Get a geometry iterator for the provided file.
:param cache: .h5 cache filepath (might not exist, will be created).
:param serializer_settings: Settings for cache serializer. Required if `cache` is provided.
"""
- it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library)
+ it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library, logger)
if cache:
assert serializer_settings, "`serializer_settings` argument is not optional if `cache` is provided."
hdf5_cache = serializers.hdf5(cache, settings, serializer_settings)
diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py
index 4354e49e90..fc3de44455 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/cost.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py
@@ -355,7 +355,8 @@ def get_cost_rate(
class CostValueUnserialiser:
def parse(self, formula: str):
- l = lark.Lark("""start: formula
+ l = lark.Lark(
+ """start: formula
formula: operand (operator operand)*
operand: value | category "(" formula ")"
value: NUMBER?
@@ -392,7 +393,8 @@ class CostValueUnserialiser:
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
- """)
+ """
+ )
start = l.parse(formula)
return self.get_formula(start.children[0])
diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py
index abaa4e4119..d67292fb1d 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/selector.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py
@@ -39,7 +39,8 @@ import ifcopenshell.util.shape
import ifcopenshell.util.system
import ifcopenshell.util.unit
-filter_elements_grammar = lark.Lark("""start: filter_group
+filter_elements_grammar = lark.Lark(
+ """start: filter_group
filter_group: facet_list ("+" facet_list)*
facet_list: facet ("," facet)*
@@ -110,9 +111,11 @@ filter_elements_grammar = lark.Lark("""start: filter_group
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
-""")
+"""
+)
-get_element_grammar = lark.Lark("""start: keys
+get_element_grammar = lark.Lark(
+ """start: keys
keys: key ("." key)*
key: quoted_string | regex_string | unquoted_string
@@ -127,9 +130,11 @@ get_element_grammar = lark.Lark("""start: keys
WS: /[ \\t\\f\\r\\n]/+
%ignore WS // Disregard spaces in text
- """)
+ """
+)
-format_grammar = lark.Lark("""start: expression
+format_grammar = lark.Lark(
+ """start: expression
?expression: add_sub
?add_sub: mul_div
@@ -188,7 +193,8 @@ format_grammar = lark.Lark("""start: expression
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
-""")
+"""
+)
class FormatTransformer(lark.Transformer):
diff --git a/src/ifcparse/IfcEntityInstanceData.cpp b/src/ifcparse/IfcEntityInstanceData.cpp
index e87c429545..88aa766086 100644
--- a/src/ifcparse/IfcEntityInstanceData.cpp
+++ b/src/ifcparse/IfcEntityInstanceData.cpp
@@ -34,7 +34,22 @@ namespace {
inline T dispatch_get_(AttributeValue::pointer_type array_, uint8_t storage_model_, size_t instance_name_, const IfcParse::declaration* entity_or_type, uint8_t index_)
{
if (storage_model_ == 0) {
- return array_.storage_ptr->get(index_);
+ try {
+ return array_.storage_ptr->get(index_);
+ } catch (const impl::storage_type_mismatch& e) {
+ throw IfcParse::IfcException(
+ // entity_or_type not passed, but in v0.9 this is beginning to make sense
+ (entity_or_type
+ ? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ")
+ : std::string("")) +
+ "Requested type <" + e.requested() + "> does not match actual type <" + e.actual() + "> at index " + std::to_string(index_));
+ } catch (const std::out_of_range& e) {
+ throw IfcParse::IfcException(
+ (entity_or_type
+ ? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ")
+ : std::string("")) +
+ e.what());
+ }
}
#ifdef IFOPSH_WITH_ROCKSDB
else {
diff --git a/src/ifcparse/IfcEntityInstanceData.h b/src/ifcparse/IfcEntityInstanceData.h
index f51345a25e..237ddb0d35 100644
--- a/src/ifcparse/IfcEntityInstanceData.h
+++ b/src/ifcparse/IfcEntityInstanceData.h
@@ -72,6 +72,83 @@ class IFC_PARSE_API Derived {};
class IFC_PARSE_API empty_aggregate_t {};
class IFC_PARSE_API empty_aggregate_of_aggregate_t {};
+namespace impl {
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "null"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "derived"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "int"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "bool"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "logical"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "real"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "string"; }
+ };
+
+ template <>
+ struct VariantTypeName> {
+ static std::string get() { return "binary"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "enumeration"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "instance"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "aggregate"; }
+ };
+
+ template
+ struct VariantTypeName> {
+ static std::string get() { return "aggregate of " + VariantTypeName::get(); }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "aggregate of instance"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "aggregate of aggregate"; }
+ };
+
+ template <>
+ struct VariantTypeName {
+ static std::string get() { return "aggregate of aggregate of instance"; }
+ };
+}
+
template
struct parameter_pack {
static constexpr size_t size = sizeof...(Args);
diff --git a/src/ifcparse/IfcLogger.cpp b/src/ifcparse/IfcLogger.cpp
index 0b454b8563..200d61e636 100644
--- a/src/ifcparse/IfcLogger.cpp
+++ b/src/ifcparse/IfcLogger.cpp
@@ -28,6 +28,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -131,6 +132,31 @@ void json_message(T& out, const IfcUtil::IfcBaseClass* current_product, Logger::
}
} // namespace
+log_message::log_message(
+ int severity,
+ const char (&code_prefix)[4],
+ uint16_t code_number,
+ const std::string& timestamp,
+ const std::string& message,
+ const IfcUtil::IfcBaseInterface* inst,
+ const IfcUtil::IfcBaseClass* current_product)
+ : severity(severity)
+ , timestamp(timestamp)
+ , message(message)
+{
+ snprintf(code, 7, "%s%03u", code_prefix, code_number);
+ if (inst) {
+ std::ostringstream oss;
+ inst->as()->toString(oss);
+ instance = oss.str();
+ }
+ if (current_product) {
+ std::ostringstream oss;
+ current_product->toString(oss);
+ product = oss.str();
+ }
+}
+
Logger& Logger::Root() {
static Logger logger;
return logger;
@@ -199,7 +225,7 @@ void Logger::Message(Logger::Severity type, const char (&code_prefix)[4], uint16
}
if (format_ == FMT_INMEMORY) {
- log_messages_.emplace_back(type, code_prefix, code_number, message, instance, current_product());
+ log_messages_.emplace_back(type, code_prefix, code_number, get_time(), message, instance, current_product());
} else if (((log2_ != nullptr) || (wlog2_ != nullptr))) {
if (format_ == FMT_PLAIN) {
if (log2_ != nullptr) {
diff --git a/src/ifcparse/IfcLogger.h b/src/ifcparse/IfcLogger.h
index 19fcdd9738..280cfcd036 100644
--- a/src/ifcparse/IfcLogger.h
+++ b/src/ifcparse/IfcLogger.h
@@ -37,24 +37,16 @@ class IFC_PARSE_API log_message {
public:
char code[7];
int severity;
- std::string message, instance, product;
+ std::string timestamp, message, instance, product;
- log_message(int severity, const char (&code_prefix)[4], uint16_t code_number, const std::string& message, const IfcUtil::IfcBaseInterface* inst = 0, const IfcUtil::IfcBaseClass* current_product = 0)
- : severity(severity)
- , message(message)
- {
- snprintf(code, 7, "%s%03u", code_prefix, code_number);
- if (inst) {
- std::ostringstream oss;
- inst->data().toString(nullptr, nullptr, 0, oss, true);
- instance = oss.str();
- }
- if (current_product) {
- std::ostringstream oss;
- current_product->toString(oss);
- product = oss.str();
- }
- }
+ log_message(
+ int severity,
+ const char (&code_prefix)[4],
+ uint16_t code_number,
+ const std::string& timestamp,
+ const std::string& message,
+ const IfcUtil::IfcBaseInterface* inst = 0,
+ const IfcUtil::IfcBaseClass* current_product = 0);
};
class IFC_PARSE_API Logger {
diff --git a/src/ifcparse/variantarray.h b/src/ifcparse/variantarray.h
index 1b00774bf7..b3fcb481b2 100644
--- a/src/ifcparse/variantarray.h
+++ b/src/ifcparse/variantarray.h
@@ -37,10 +37,28 @@ variant - which is the maximum size of its constituents - is reduced.
#include
#include
#include
-
-#include "IfcException.h"
+#include
namespace impl {
+ class storage_type_mismatch : public std::exception {
+ private:
+ std::string requested_, actual__, message_;
+
+ public:
+ storage_type_mismatch(const std::string& requested, const std::string& actual)
+ : requested_(requested), actual__(actual), message_("Requested type " + requested_ + " does not match actual type " + actual__) {}
+
+ const char* what() const noexcept override {
+ return message_.c_str();
+ }
+
+ const std::string& requested() const { return requested_; }
+ const std::string& actual() const { return actual__; }
+ };
+
+ template
+ struct VariantTypeName;
+
// Trait to detect unique_ptr
template struct is_unique_ptr : std::false_type {};
template
@@ -166,14 +184,13 @@ public:
using U = std::decay_t;
static_assert(::impl::TypeIndex_v < sizeof...(Types), "Type not supported by variant");
if (index >= size()) {
- throw std::out_of_range("Index out of range");
+ throw std::out_of_range("Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()));
}
destroy_at_index(index);
size_and_indices_[index + 1] = ::impl::TypeIndex_v;
using V = typename std::tuple_element<::impl::TypeIndex_v, ::impl::MapTypes_t>::type;
- // std::wcout << "setting " << index << " to " << typeid(V).name() << " (" << ::impl::TypeIndex_v << ")" << std::endl;
if constexpr (::impl::is_unique_ptr::value) {
new(&storage_[index]) V(new U(value));
} else {
@@ -187,8 +204,8 @@ public:
std::size_t index(std::size_t index) const {
if (index >= size()) {
- throw IfcParse::IfcException(
- "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
+ throw std::out_of_range(
+ "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
);
}
return size_and_indices_[index + 1];
@@ -197,8 +214,8 @@ public:
template
T& get(std::size_t index) {
if (index >= size()) {
- throw IfcParse::IfcException(
- "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
+ throw std::out_of_range(
+ "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
);
}
if (!has(index)) {
@@ -220,17 +237,16 @@ public:
template
const T& get(std::size_t index) const {
if (index >= size()) {
- throw IfcParse::IfcException(
- "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
+ throw std::out_of_range(
+ "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
);
}
if (size_and_indices_[index + 1] != ::impl::TypeIndex::value) {
// @todo this IfcException is silly. Figure out what
// to do, but at the moment it is specifically caught
// in various places.
- throw IfcParse::IfcException(
- "Type held at index " + std::to_string(index) + " is " +
- get_type_name(size_and_indices_[index + 1]) + " and not " + typeid(T).name()
+ throw impl::storage_type_mismatch(
+ ::impl::VariantTypeName::get(), get_type_name(size_and_indices_[index + 1])
);
}
using V = typename std::tuple_element<::impl::TypeIndex_v, ::impl::MapTypes_t>::type;
@@ -244,8 +260,8 @@ public:
template
auto apply_visitor(Visitor&& visitor, std::size_t index) const {
if (index >= size()) {
- throw IfcParse::IfcException(
- "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
+ throw std::out_of_range(
+ "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
);
}
return apply_visitor_impl(std::forward(visitor), index, std::integral_constant{});
@@ -312,19 +328,19 @@ private:
}
template
- const char* get_type_name_impl(size_t i) const {
+ std::string get_type_name_impl(size_t i) const {
if constexpr (I == 0) {
return "";
} else {
if (i == I - 1) {
- return typeid(std::tuple_element_t>).name();
+ return ::impl::VariantTypeName>>::get();
} else {
return get_type_name_impl(i);
}
}
}
- const char* get_type_name(size_t i) const {
+ std::string get_type_name(size_t i) const {
return get_type_name_impl(i);
}
};
diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json
index 90a6743443..947e26ded5 100644
--- a/src/ifctester/webapp/package-lock.json
+++ b/src/ifctester/webapp/package-lock.json
@@ -3177,9 +3177,9 @@
}
},
"node_modules/tar": {
- "version": "7.5.11",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
- "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
+ "version": "7.5.16",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
+ "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
diff --git a/src/ifcwrap/CMakeLists.txt b/src/ifcwrap/CMakeLists.txt
index 16e3a86565..21c11d3ae0 100644
--- a/src/ifcwrap/CMakeLists.txt
+++ b/src/ifcwrap/CMakeLists.txt
@@ -17,6 +17,23 @@
# #
################################################################################
+cmake_minimum_required(VERSION 3.21)
+
+if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
+ project(IfcOpenShellPython LANGUAGES CXX)
+ set(IFCOPENSHELL_IFCWRAP_STANDALONE ON)
+else()
+ set(IFCOPENSHELL_IFCWRAP_STANDALONE OFF)
+endif()
+
+if(NOT DEFINED CMAKE_CXX_STANDARD)
+ set(CMAKE_CXX_STANDARD 17)
+endif()
+if(CMAKE_CXX_STANDARD LESS 17)
+ message(FATAL_ERROR "C++17 or newer is required.")
+endif()
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
if(POLICY CMP0148) # 3.27
cmake_policy(SET CMP0148 OLD)
endif()
@@ -24,6 +41,35 @@ if(POLICY CMP0177) # 3.31
cmake_policy(SET CMP0177 OLD)
endif()
+set(_ifcwrap_feature_definitions "")
+if(IFCOPENSHELL_IFCWRAP_STANDALONE)
+ get_filename_component(_ifcwrap_repo_root "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
+ list(PREPEND CMAKE_MODULE_PATH "${_ifcwrap_repo_root}/cmake")
+
+ find_package(IfcOpenShell REQUIRED)
+
+ set(WITH_OPENCASCADE "${IFCOPENSHELL_WITH_OPENCASCADE}")
+ set(WITH_CGAL "${IFCOPENSHELL_WITH_CGAL}")
+ set(SWIG_DEFINES "")
+
+ if(IFCOPENSHELL_WITH_OPENCASCADE)
+ list(APPEND SWIG_DEFINES -DIFOPSH_WITH_OPENCASCADE)
+ list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_OPENCASCADE)
+ endif()
+ if(IFCOPENSHELL_WITH_CGAL)
+ list(APPEND SWIG_DEFINES -DIFOPSH_WITH_CGAL)
+ list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_CGAL)
+ endif()
+ if(IFCOPENSHELL_IFCXML)
+ list(APPEND SWIG_DEFINES -DWITH_IFCXML)
+ list(APPEND _ifcwrap_feature_definitions WITH_IFCXML)
+ endif()
+ if(IFCOPENSHELL_WITH_ROCKSDB)
+ list(APPEND SWIG_DEFINES -DIFOPSH_WITH_ROCKSDB)
+ list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_ROCKSDB)
+ endif()
+endif()
+
find_package(SWIG)
if(NOT SWIG_FOUND)
message(
@@ -68,7 +114,15 @@ include_directories(BEFORE ${CMAKE_CURRENT_SOURCE_DIR})
set(CMAKE_SWIG_FLAGS ${SWIG_DEFINES})
if(WITH_CGAL)
- set(LIBSVGFILL svgfill)
+ if(IFCOPENSHELL_IFCWRAP_STANDALONE)
+ if(TARGET IfcOpenShell::svgfill)
+ set(LIBSVGFILL IfcOpenShell::svgfill)
+ else()
+ message(FATAL_ERROR "IfcOpenShell was built with CGAL, but the exported svgfill target was not found.")
+ endif()
+ else()
+ set(LIBSVGFILL svgfill)
+ endif()
endif()
set_source_files_properties(IfcPython.i PROPERTIES CPLUSPLUS ON)
@@ -113,7 +167,21 @@ else()
endif()
target_link_libraries(ifcopenshell_wrapper PRIVATE Python::Module)
-set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${IFCOPENSHELL_LIBRARIES})
+if(_ifcwrap_feature_definitions)
+ target_compile_definitions(ifcopenshell_wrapper PRIVATE ${_ifcwrap_feature_definitions})
+endif()
+if(IFCOPENSHELL_IFCWRAP_STANDALONE)
+ set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES})
+ set(_ifcwrap_geometry_libraries ${IFCOPENSHELL_GEOMETRY_LIBRARIES})
+ set(_ifcwrap_cgal_libraries "")
+ set(_ifcwrap_swig_depends "")
+else()
+ set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES})
+ set(_ifcwrap_geometry_libraries IfcGeom ${kernel_libraries})
+ set(_ifcwrap_cgal_libraries ${CGAL_LIBRARIES})
+ set(_ifcwrap_swig_depends ${IFCOPENSHELL_LIBRARIES})
+endif()
+set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${_ifcwrap_swig_depends})
if(WASM_BUILD)
# SIDE_MODULE=1 - add to .so all symbols from linked archives (default used by pyodide).
# Since currently libIfcGeom.a seems to be linked twice it results in duplicated symbols and compilation errors.
@@ -134,14 +202,14 @@ if("$ENV{LDFLAGS}" MATCHES ".undefined.suppress")
# On osx there is some state in the python dylib. With `-Wl,undefined,suppress` we can ignore the missing symbols at compile time.
target_link_libraries(
ifcopenshell_wrapper
- PRIVATE ${IFCOPENSHELL_LIBRARIES} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL}
+ PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL}
)
else()
- target_link_libraries(ifcopenshell_wrapper PRIVATE ${IFCOPENSHELL_LIBRARIES} ${LIBSVGFILL})
+ target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${LIBSVGFILL})
endif()
-target_link_libraries(ifcopenshell_wrapper PRIVATE ${CGAL_LIBRARIES})
-target_link_libraries(ifcopenshell_wrapper PRIVATE IfcGeom ${kernel_libraries})
-if((NOT WIN32) AND BUILD_SHARED_LIBS)
+target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_cgal_libraries})
+target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_geometry_libraries})
+if((NOT WIN32) AND BUILD_SHARED_LIBS AND COMMAND SET_INSTALL_RPATHS)
SET_INSTALL_RPATHS(ifcopenshell_wrapper "${IFCDIRS};${OCC_LIBRARY_DIR}")
endif()
@@ -194,7 +262,7 @@ if(Python_Interpreter_FOUND OR PYTHON_MODULE_INSTALL_DIR)
endif()
endforeach()
install(
- FILES "${CMAKE_BINARY_DIR}/ifcwrap/ifcopenshell_wrapper.py"
+ FILES "${CMAKE_CURRENT_BINARY_DIR}/ifcopenshell_wrapper.py"
DESTINATION "${python_package_dir}/ifcopenshell"
)
install(TARGETS ifcopenshell_wrapper DESTINATION "${python_package_dir}/ifcopenshell")
diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i
index a9cf372dc1..10d9083403 100644
--- a/src/ifcwrap/IfcGeomWrapper.i
+++ b/src/ifcwrap/IfcGeomWrapper.i
@@ -637,31 +637,31 @@ struct ShapeRTTI : public boost::static_visitor
// I couldn't get the vector typemap to be applied when %extending Iterator constructor.
// anyway it does not matter as SWIG generates C code without actual constructors
%inline %{
- IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads) {
- return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, num_threads);
- }
-
- IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) {
- std::set elems_set(elems.begin(), elems.end());
- IfcGeom::entity_filter ef{ include, false, elems_set };
- return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {ef}, num_threads);
- }
-
- IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) {
- std::set elems_set(elems.begin(), elems.end());
- IfcGeom::attribute_filter af;
- af.attribute_name = "GlobalId";
- af.populate(elems_set);
- af.include = include;
- return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads);
- }
-
- IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) {
- std::set elems_set(elems.begin(), elems.end());
- IfcGeom::instance_id_filter af(include, false, elems_set);
- return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads);
- }
-%}
+ IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads, Logger& logger = Logger::Root()) {
+ return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, num_threads, logger);
+ }
+
+ IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
+ std::set elems_set(elems.begin(), elems.end());
+ IfcGeom::entity_filter ef{ include, false, elems_set };
+ return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {ef}, num_threads, logger);
+ }
+
+ IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
+ std::set elems_set(elems.begin(), elems.end());
+ IfcGeom::attribute_filter af;
+ af.attribute_name = "GlobalId";
+ af.populate(elems_set);
+ af.include = include;
+ return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger);
+ }
+
+ IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
+ std::set elems_set(elems.begin(), elems.end());
+ IfcGeom::instance_id_filter af(include, false, elems_set);
+ return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger);
+ }
+%}
%extend IfcGeom::Representation::Triangulation {
@@ -1288,11 +1288,11 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
}
}
- std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons) {
- std::vector r;
- if (svgfill::arrange_polygons(settings, polygons, r)) {
- return r;
- } else {
+ std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons, Logger& logger = Logger::Root()) {
+ std::vector r;
+ if (svgfill::arrange_polygons(settings, polygons, r, logger)) {
+ return r;
+ } else {
throw std::runtime_error("Failed to arrange polygons");
}
}
diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i
index ab750543ef..9826c03da2 100644
--- a/src/ifcwrap/IfcParseWrapper.i
+++ b/src/ifcwrap/IfcParseWrapper.i
@@ -712,10 +712,10 @@ private:
%newobject stream_from_string;
%inline %{
- IfcParse::IfcFile* open(const std::string& fn, bool readonly=false) {
+ IfcParse::IfcFile* open(const std::string& fn, bool readonly=false, Logger& logger=Logger::Root()) {
IfcParse::IfcFile* f;
Py_BEGIN_ALLOW_THREADS;
- f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly);
+ f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly, logger);
Py_END_ALLOW_THREADS;
return f;
}
@@ -1238,8 +1238,11 @@ private:
}
%pythoncode %{
severity_string = property(severity_string)
+ def to_dict(self):
+ keys = ("timestamp", "severity", "code", "message", "instance", "product")
+ return dict(zip(keys, self.to_tuple()))
def to_tuple(self):
- return self.severity_string, self.code, self.message, self.instance
+ return self.timestamp, self.severity_string, self.code, self.message, self.instance, self.product
def __eq__(self, other):
return type(self) == type(other) and self.to_tuple() == other.to_tuple()
def __hash__(self):
diff --git a/src/serializers/CMakeLists.txt b/src/serializers/CMakeLists.txt
index 94b30a0e38..b18c8a59c3 100644
--- a/src/serializers/CMakeLists.txt
+++ b/src/serializers/CMakeLists.txt
@@ -36,7 +36,7 @@ target_link_libraries(
IfcParse
)
-install(TARGETS Serializers)
+install(TARGETS Serializers EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
# Can't use `PUBLIC_HEADER` since we need two folders.
install(FILES ${SERIALIZERS_H_FILES} DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/serializers/")
diff --git a/src/serializers/schema_dependent/CMakeLists.txt b/src/serializers/schema_dependent/CMakeLists.txt
index 73c1e5d3e7..a9c1107bcc 100644
--- a/src/serializers/schema_dependent/CMakeLists.txt
+++ b/src/serializers/schema_dependent/CMakeLists.txt
@@ -13,6 +13,7 @@ foreach(schema ${SCHEMA_VERSIONS})
if(NOT WASM_BUILD)
target_link_libraries(Serializers_ifc${schema} ${OpenCASCADE_LIBRARIES})
endif()
+ install(TARGETS Serializers_ifc${schema} EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
endforeach()
set(SERIALIZER_SCHEMA_LIBRARIES ${SERIALIZER_SCHEMA_LIBRARIES} PARENT_SCOPE)
diff --git a/src/svgfill/CMakeLists.txt b/src/svgfill/CMakeLists.txt
index 0d9764013a..ea4e64636f 100644
--- a/src/svgfill/CMakeLists.txt
+++ b/src/svgfill/CMakeLists.txt
@@ -49,7 +49,7 @@ file(GLOB LIB_H_FILES src/*.h)
file(GLOB LIB_CPP_FILES src/svgfill.cpp src/arrange_polygons.cpp)
set(LIB_SRC_FILES ${LIB_H_FILES} ${LIB_CPP_FILES})
add_library(svgfill ${LIB_SRC_FILES})
-target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL)
+target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL IfcParse)
set_target_properties(svgfill PROPERTIES PUBLIC_HEADER "${LIB_H_FILES}")
add_executable(svgfill_exe src/main.cpp)
diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp
index 260b22809e..5a99125adb 100644
--- a/src/svgfill/src/arrange_polygons.cpp
+++ b/src/svgfill/src/arrange_polygons.cpp
@@ -5,6 +5,8 @@
#include "svgfill.h"
#endif
+#include "../../ifcparse/IfcLogger.h"
+
#include
#include
#include
@@ -1602,7 +1604,8 @@ std::map> snap_points_to_box_axes(
DebugWriter& debug,
const CenterLineGraphData& graph,
const std::vector& boxes,
- const K::FT& max_projection_distance) {
+ const K::FT& max_projection_distance,
+ Logger& logger) {
std::vector snapped_points(graph.points.size());
for (size_t i = 0; i < graph.points.size(); ++i) {
@@ -1685,7 +1688,11 @@ std::map> snap_points_to_box_axes(
debug.write_segment(graph.points[i], best.projection, "snap_candidate_4");
} else {
snapped_points[i] = graph.points[i];
- std::cout << "Warning: snapping distance exceeding distance: " << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) << " > " << max_projection_distance << std::endl;
+ std::ostringstream message;
+ message << "Snapping distance exceeds maximum distance: "
+ << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length()))
+ << " > " << max_projection_distance;
+ logger.Message(Logger::LOG_WARNING, "ARR", 1, message.str());
}
}
@@ -1711,7 +1718,8 @@ Graph2D join_segment_runs(
DebugWriter& debug,
const std::map>& line_graph,
const std::map>& midpoint_to_segment,
- const K::FT& max_projection_distance) {
+ const K::FT& max_projection_distance,
+ Logger& logger) {
auto graph = make_center_line_graph_data(line_graph, midpoint_to_segment);
auto runs = runs_from_graph(graph);
runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) {
@@ -1742,7 +1750,7 @@ Graph2D join_segment_runs(
}
debug.write_polygons(run_polygons, "merged_boxes");
- auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance);
+ auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance, logger);
return Graph2D(snapped_graph);
}
@@ -2239,7 +2247,9 @@ extend_end_vertices_based_on_input_simple(
DebugWriter& debug_output,
const Graph2D& G,
const Polygon_list& outer_perimiter,
- const K::FT& max_projection_distance, int pass)
+ const K::FT& max_projection_distance,
+ int pass,
+ Logger& logger)
{
auto max_intersection_distance = max_projection_distance / 4;
@@ -2389,9 +2399,9 @@ extend_end_vertices_based_on_input_simple(
}
}
if (within_any_perimeter) {
- std::cout << "Within boundary but still no solution given" << std::endl;
+ logger.Message(Logger::LOG_WARNING, "ARR", 2, "Within boundary but no projection or intersection solution was found");
} else {
- std::cout << "Outside of all boundaries" << std::endl;
+ logger.Message(Logger::LOG_WARNING, "ARR", 3, "Point is outside all boundaries");
}
return boost::optional{};
};
@@ -2404,13 +2414,18 @@ extend_end_vertices_based_on_input_simple(
auto& M = it->first;
if (auto result = process_point(M, *it->second.begin())) {
if (*result == M) {
- std::cout << "Point already on perimeter (" << M.x() << " " << M.y() << ")" << std::endl;
+ std::ostringstream message;
+ message << "Point is already on perimeter (" << M.x() << " " << M.y() << ")";
+ logger.Message(Logger::LOG_NOTICE, "ARR", 4, message.str());
continue;
}
auto d = (M - *result).squared_length();
solutions.emplace_back(d, M, *it->second.begin());
} else {
- std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 1] (" << M.x() << " " << M.y() << ")" << std::endl;
+ std::ostringstream message;
+ message << "Unable to find projection or intersection point for interior boundary pass "
+ << pass << " [round 1] (" << M.x() << " " << M.y() << ")";
+ logger.Message(Logger::LOG_WARNING, "ARR", 5, message.str());
}
}
}
@@ -2424,12 +2439,17 @@ extend_end_vertices_based_on_input_simple(
debug_output.write_segment(point, *result, "exterior_constructed_segment");
auto d = CGAL::squared_distance(point, *result);
- std::cout << "Distance: " << std::sqrt(CGAL::to_double(d)) << std::endl;
+ std::ostringstream message;
+ message << "Projection or intersection distance: " << std::sqrt(CGAL::to_double(d));
+ logger.Message(Logger::LOG_DEBUG, "ARR", 6, message.str());
validation_segments.emplace_back(to_3d(point), to_3d(*result));
auto inserted_it = std::prev(validation_segments.end());
validation_tree.insert(inserted_it, validation_segments.end());
} else {
- std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 2] (" << point.x() << " " << point.y() << ")" << std::endl;
+ std::ostringstream message;
+ message << "Unable to find projection or intersection point for interior boundary pass "
+ << pass << " [round 2] (" << point.x() << " " << point.y() << ")";
+ logger.Message(Logger::LOG_WARNING, "ARR", 7, message.str());
}
}
@@ -2528,7 +2548,7 @@ class Segment_2_less {
}
};
-std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) {
+std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right, Logger& logger) {
using Walk_pl = CGAL::Arr_walk_along_line_point_location;
Walk_pl walk_pl(right);
@@ -2610,7 +2630,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
if (visited_faces_on_right.count(*v) > 0) {
// Maybe we should be more permissive, try some other points etc.
return_values.push_back(0);
- std::cout << "Already visited face on right, skipping point\n";
+ logger.Message(Logger::LOG_WARNING, "ARR", 8, "Already visited face on right; skipping point");
} else {
// convert arr facet to polygon with holes
auto polygon_exterior = circ_to_poly((*v)->outer_ccb());
@@ -2648,7 +2668,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
max_deviation_poly_pair = {pwh.outer_boundary(), pwh_right.outer_boundary()};
}
} else {
- std::cout << "No intersection, skipping point\n";
+ logger.Message(Logger::LOG_WARNING, "ARR", 9, "No intersection; skipping point");
return_values.push_back(0);
}
}
@@ -2670,7 +2690,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
return return_values;
}
-void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) {
+void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold, Logger& logger) {
using SK = CGAL::Simple_cartesian;
CGAL::Cartesian_converter C{};
@@ -2886,7 +2906,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
}
}
if (!removed) {
- std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl;
+ logger.Message(Logger::LOG_WARNING, "ARR", 10, "Unable to locate edge for removal; skipping");
}
}
@@ -2971,7 +2991,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
#else
auto arr_copy = arr;
process_modifications(arr_copy, to_remove_this_path, to_insert_this_path);
- auto ious = arrangement_cell_iou(arr, arr_copy);
+ auto ious = arrangement_cell_iou(debug_output, arr, arr_copy, logger);
for (auto& iou : ious) {
std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl;
}
@@ -3303,28 +3323,36 @@ class timer {
public:
class entry {
public:
- entry() {}
+ entry() : logger_(nullptr) {}
- entry(std::map::const_iterator start_it)
- : start_it(start_it) {}
+ entry(
+ std::map::const_iterator start_it,
+ Logger& logger)
+ : start_it(start_it)
+ , logger_(&logger) {}
void stop() {
- if (start_it) {
+ if (start_it && logger_) {
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration(end - start_it.value()->second).count();
- std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl;
+ std::ostringstream message;
+ message << "Timing for " << start_it.value()->first << ": " << duration << " ms";
+ logger_->Message(Logger::LOG_PERF, "ARR", 11, message.str());
}
}
private:
std::optional::const_iterator> start_it;
+ Logger* logger_;
};
- timer(bool enabled = true) : enabled_(enabled) {}
+ timer(Logger& logger, bool enabled = true)
+ : logger_(logger)
+ , enabled_(enabled) {}
entry start(const std::string& name) {
if (enabled_) {
- return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first);
+ return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first, logger_);
} else {
return entry();
}
@@ -3336,6 +3364,7 @@ class timer {
std::chrono::high_resolution_clock::time_point>
timings_;
+ Logger& logger_;
bool enabled_;
};
@@ -3351,7 +3380,12 @@ size_t delete_same_facet_edge_pairs(Arrangement_2& arr) {
return n_deleted;
}
-void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) {
+void arrange_cgal_polygons(
+ svgfill::arrange_polygon_settings settings,
+ const std::vector& input_polygons_,
+ std::vector& output_polygons,
+ Logger& logger,
+ double polygon_offset_distance = -1.) {
static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1;
// even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied
@@ -3371,7 +3405,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
debug_output = DebugWriter(false, "");
}
- timer timer(settings.debug_output);
+ timer timer(logger, settings.debug_output);
auto t0 = timer.start("input");
@@ -3578,7 +3612,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
for (int i = 0; i < 2; ++i) {
auto it = line_graph.find(e.first);
if (it == line_graph.end()) {
- std::cerr << "Warning: unable to locate vertex for elimination, skipping" << std::endl;
+ logger.Message(Logger::LOG_WARNING, "ARR", 12, "Unable to locate vertex for elimination; skipping");
continue;
}
auto& neighbours = it->second;
@@ -3607,7 +3641,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
};
if (settings.line_cleaning_algo == 0) {
- G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4);
+ G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4, logger);
Arrangement_2 arr;
G.to_arrangement(arr);
Graph2D G2;
@@ -3629,8 +3663,8 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
bool fallback_to_line_cleaning_algo_1 = false;
if (settings.line_cleaning_algo == 0) {
- segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0);
- segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1);
+ segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0, logger);
+ segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1, logger);
Arrangement_2 arr_clean;
G.to_arrangement(arr_clean);
@@ -3668,7 +3702,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
debug_output.write_polygons(arr_clean, "iou_left");
debug_output.write_polygons(arr_orig, "iou_right");
- auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig);
+ auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig, logger);
/*
for (auto& iou : ious) {
std::cout << " " << CGAL::to_double(iou - 1);
@@ -3679,7 +3713,10 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
auto it = std::min_element(ious.begin(), ious.end());
if (it != ious.end() && (*it < 0.45)) {
- std::cerr << "Significant difference between cleaned and original arrangement, using original for topology reconstruction: " << *it << std::endl;
+ std::ostringstream message;
+ message << "Significant difference between cleaned and original arrangement; using original for topology reconstruction: "
+ << *it;
+ logger.Message(Logger::LOG_WARNING, "ARR", 13, message.str());
fallback_to_line_cleaning_algo_1 = true;
apply_line_cleaning_algo_1();
} else {
@@ -3754,7 +3791,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
if (settings.perform_cleanup && settings.line_cleaning_algo != 0) {
remove_colinear_vertices(arr);
double threshold;
- clean_noisy_paths(debug_output, arr, segment_lookup, threshold);
+ clean_noisy_paths(debug_output, arr, segment_lookup, threshold, logger);
remove_colinear_vertices(arr);
// clean_noisy_bounds(debug_output, arr, segment_lookup, threshold);
}
@@ -3773,7 +3810,11 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
#ifndef SVGFILL_MAIN
-bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged) {
+bool svgfill::arrange_polygons(
+ arrange_polygon_settings settings,
+ const std::vector& polygons,
+ std::vector& arranged,
+ Logger& logger) {
std::vector cgal_polygons, cgal_polygons_out;
std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) {
Polygon_2 result;
@@ -3782,7 +3823,7 @@ bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vec
});
return result;
});
- arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out);
+ arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out, logger);
std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) {
svgfill::polygon_2 result;
std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) {
@@ -3812,6 +3853,9 @@ Polygon_2 create_rectangle(T x_min, T y_min, T x_max, T y_max) {
int main(int argc, char** argv) {
std::vector input_polygons, output;
+ Logger logger;
+ logger.SetOutput(&std::cout, &std::cerr);
+ logger.Verbosity(Logger::LOG_PERF);
if (argc == 2) {
using json = nlohmann::json;
@@ -3820,7 +3864,7 @@ int main(int argc, char** argv) {
file >> jsonData;
size_t i = 0;
for (const auto& item : jsonData.items()) {
- std::cout << "i " << i << std::endl;
+ logger.Message(Logger::LOG_NOTICE, "ARR", 14, "Processing arrangement " + std::to_string(i));
i++;
input_polygons.clear();
const auto& polygonsData = item.value();
@@ -3832,7 +3876,7 @@ int main(int argc, char** argv) {
input_polygons.back().push_back(CGAL::Point_2(x, y));
}
}
- arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output);
+ arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger);
break;
}
return 0;
@@ -3845,7 +3889,7 @@ int main(int argc, char** argv) {
input_polygons = { rect1, rect2, rect3, rect4, rect5 };
}
- arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output);
+ arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger);
return 0;
}
diff --git a/src/svgfill/src/svgfill.h b/src/svgfill/src/svgfill.h
index 2588636a8d..5bc3fa39e9 100644
--- a/src/svgfill/src/svgfill.h
+++ b/src/svgfill/src/svgfill.h
@@ -40,6 +40,8 @@
#include
#include
+class Logger;
+
namespace svgfill {
typedef std::array point_2;
typedef std::array line_segment_2;
@@ -133,7 +135,7 @@ namespace svgfill {
double subdivision_factor = 16.;
};
- SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged);
+ SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged, Logger& logger);
}
#endif