Merge branch 'v0.8.0' into ifcmax/initial-refresh

This commit is contained in:
Josef Wienerroither
2026-06-18 10:46:27 +02:00
90 changed files with 7858 additions and 372 deletions
+155
View File
@@ -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
+131
View File
@@ -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 <http://www.gnu.org/licenses/>. #
# #
################################################################################
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)
+38 -4
View File
@@ -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")
+1 -1
View File
@@ -106,7 +106,7 @@ endif
endif # def PLATFORM
# Current build commit hash.
OLD:=1c5b825
OLD:=3e7b739
.PHONY: bump
bump:
ifndef NEW
+1
View File
@@ -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,
+12 -1
View File
@@ -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
+26 -5
View File
@@ -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()
+6
View File
@@ -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
@@ -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
@@ -0,0 +1,98 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import 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
@@ -0,0 +1,170 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
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"}
@@ -0,0 +1,107 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
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
@@ -0,0 +1,86 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
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")
+38 -39
View File
@@ -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
@@ -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)
+11 -1
View File
@@ -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,
+5 -1
View File
@@ -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
@@ -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()
+16 -5
View File
@@ -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.
+648 -11
View File
@@ -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)
+72 -1
View File
@@ -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_<name>`` and
``bim.cancel_editing_<name>`` 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"}
+485 -94
View File
@@ -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):
@@ -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):
@@ -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,
@@ -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"}
@@ -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"]
@@ -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:
+18
View File
@@ -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()
+99
View File
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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}")
+14 -3
View File
@@ -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(
+10
View File
@@ -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
+2
View File
@@ -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
+127 -2
View File
@@ -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
+231
View File
@@ -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
+991
View File
@@ -0,0 +1,991 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
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_<i>_Name`` and ``Box_<i>_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 TrueFalse 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)
+150
View File
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
+161 -4
View File
@@ -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)
+69 -8
View File
@@ -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()
+11
View File
@@ -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.
+69
View File
@@ -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,
+1
View File
@@ -8,6 +8,7 @@ markers =
brick
bsdd
classification
clip_box
context
cost
covering
+41
View File
@@ -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.
@@ -0,0 +1,692 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import 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 TrueFalse
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()
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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()
@@ -0,0 +1,87 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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])
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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.
# ---------------------------------------------------------------------------
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
(railcorner, cornerwall) × 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()
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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()
@@ -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():
@@ -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
# ----------------------------------------------------------------------------
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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"
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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)
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
+7
View File
@@ -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)
+218
View File
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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",
)
+13
View File
@@ -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
+87 -1
View File
@@ -16,7 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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))
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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, "<kind>", )`` positional string at index 1, the
conventional emit shape in ``find_rels`` / ``find_rels_for_element``.
- ``kind = "<a>" if else "<b>"`` 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."
)
+31
View File
@@ -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()
+24
View File
@@ -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]:
+27 -5
View File
@@ -25,8 +25,6 @@
#include "ifcparse/IfcFile.h"
#include "ifcparse/IfcLogger.h"
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include <boost/preprocessor/stringize.hpp>
#include <boost/preprocessor/seq/for_each.hpp>
@@ -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 <iomanip>
#if USE_VLD
@@ -58,9 +80,9 @@ struct is_ifc4_or_higher<T, std::void_t<decltype(T::IfcMaterialDefinition)>> : s
typedef std::map<std::string, std::map<std::string, std::string>> 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<example_element_type>();
auto elements = file.instances_by_type<element_t>();
std::cout << "Found " << elements->size() << " elements in " << argv[1] << ":" << std::endl;
+17 -11
View File
@@ -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):
+42
View File
@@ -17,6 +17,7 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
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, ""]]
+6 -1
View File
@@ -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;
}
+2 -2
View File
@@ -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
@@ -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)
+5 -2
View File
@@ -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)
@@ -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))
)
@@ -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)
@@ -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)
@@ -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)
@@ -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])
@@ -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):
+16 -1
View File
@@ -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<T>(index_);
try {
return array_.storage_ptr->get<T>(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 {
+77
View File
@@ -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<Blank> {
static std::string get() { return "null"; }
};
template <>
struct VariantTypeName<Derived> {
static std::string get() { return "derived"; }
};
template <>
struct VariantTypeName<int> {
static std::string get() { return "int"; }
};
template <>
struct VariantTypeName<bool> {
static std::string get() { return "bool"; }
};
template <>
struct VariantTypeName<boost::logic::tribool> {
static std::string get() { return "logical"; }
};
template <>
struct VariantTypeName<double> {
static std::string get() { return "real"; }
};
template <>
struct VariantTypeName<std::string> {
static std::string get() { return "string"; }
};
template <>
struct VariantTypeName<boost::dynamic_bitset<>> {
static std::string get() { return "binary"; }
};
template <>
struct VariantTypeName<EnumerationReference> {
static std::string get() { return "enumeration"; }
};
template <>
struct VariantTypeName<IfcUtil::IfcBaseClass*> {
static std::string get() { return "instance"; }
};
template <>
struct VariantTypeName<empty_aggregate_t> {
static std::string get() { return "aggregate"; }
};
template <typename T, typename Allocator>
struct VariantTypeName<std::vector<T, Allocator>> {
static std::string get() { return "aggregate of " + VariantTypeName<T>::get(); }
};
template <>
struct VariantTypeName<aggregate_of_instance::ptr> {
static std::string get() { return "aggregate of instance"; }
};
template <>
struct VariantTypeName<empty_aggregate_of_aggregate_t> {
static std::string get() { return "aggregate of aggregate"; }
};
template <>
struct VariantTypeName<aggregate_of_aggregate_of_instance::ptr> {
static std::string get() { return "aggregate of aggregate of instance"; }
};
}
template<typename... Args>
struct parameter_pack {
static constexpr size_t size = sizeof...(Args);
+27 -1
View File
@@ -28,6 +28,7 @@
#include <boost/property_tree/ptree.hpp>
#include <boost/version.hpp>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <iomanip>
#include <iostream>
@@ -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<IfcUtil::IfcBaseClass>()->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) {
+9 -17
View File
@@ -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 {
+34 -18
View File
@@ -37,10 +37,28 @@ variant - which is the maximum size of its constituents - is reduced.
#include <cstring>
#include <cstddef>
#include <limits>
#include "IfcException.h"
#include <exception>
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 <typename T>
struct VariantTypeName;
// Trait to detect unique_ptr
template <typename...> struct is_unique_ptr : std::false_type {};
template<class T, typename... Args>
@@ -166,14 +184,13 @@ public:
using U = std::decay_t<T>;
static_assert(::impl::TypeIndex_v<U, Types...> < 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<U, Types...>;
using V = typename std::tuple_element<::impl::TypeIndex_v<U, Types...>, ::impl::MapTypes_t<Types... >>::type;
// std::wcout << "setting " << index << " to " << typeid(V).name() << " (" << ::impl::TypeIndex_v<U, Types...> << ")" << std::endl;
if constexpr (::impl::is_unique_ptr<V>::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<typename T>
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<T>(index)) {
@@ -220,17 +237,16 @@ public:
template<typename T>
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<T, Types...>::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<T>::get(), get_type_name(size_and_indices_[index + 1])
);
}
using V = typename std::tuple_element<::impl::TypeIndex_v<T, Types...>, ::impl::MapTypes_t<Types... >>::type;
@@ -244,8 +260,8 @@ public:
template<typename Visitor>
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>(visitor), index, std::integral_constant<std::size_t, sizeof...(Types)>{});
@@ -312,19 +328,19 @@ private:
}
template <size_t I>
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<I - 1, std::tuple<Types...>>).name();
return ::impl::VariantTypeName<std::tuple_element_t<I - 1, std::tuple<Types...>>>::get();
} else {
return get_type_name_impl<I - 1>(i);
}
}
}
const char* get_type_name(size_t i) const {
std::string get_type_name(size_t i) const {
return get_type_name_impl<sizeof...(Types)>(i);
}
};
+3 -3
View File
@@ -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": {
+76 -8
View File
@@ -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")
+30 -30
View File
@@ -637,31 +637,31 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
// I couldn't get the vector<string> 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<std::string> elems, bool include, int num_threads) {
std::set<std::string> 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<std::string> elems, bool include, int num_threads) {
std::set<std::string> 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<int> elems, bool include, int num_threads) {
std::set<int> 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<std::string> elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
std::set<std::string> 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<std::string> elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
std::set<std::string> 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<int> elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
std::set<int> 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<svgfill::polygon_2> arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons) {
std::vector<svgfill::polygon_2> r;
if (svgfill::arrange_polygons(settings, polygons, r)) {
return r;
} else {
std::vector<svgfill::polygon_2> arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons, Logger& logger = Logger::Root()) {
std::vector<svgfill::polygon_2> r;
if (svgfill::arrange_polygons(settings, polygons, r, logger)) {
return r;
} else {
throw std::runtime_error("Failed to arrange polygons");
}
}
+6 -3
View File
@@ -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):
+1 -1
View File
@@ -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/")
@@ -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)
+1 -1
View File
@@ -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)
+82 -38
View File
@@ -5,6 +5,8 @@
#include "svgfill.h"
#endif
#include "../../ifcparse/IfcLogger.h"
#include <CGAL/Exact_predicates_exact_constructions_kernel.h>
#include <CGAL/Boolean_set_operations_2.h>
#include <CGAL/Polygon_2.h>
@@ -1602,7 +1604,8 @@ std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
DebugWriter& debug,
const CenterLineGraphData& graph,
const std::vector<MergedBoxRecord>& boxes,
const K::FT& max_projection_distance) {
const K::FT& max_projection_distance,
Logger& logger) {
std::vector<Point_2> snapped_points(graph.points.size());
for (size_t i = 0; i < graph.points.size(); ++i) {
@@ -1685,7 +1688,11 @@ std::map<Point_2, std::vector<Point_2>> 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<K> join_segment_runs(
DebugWriter& debug,
const std::map<Point_2, std::vector<Point_2>>& line_graph,
const std::map<Point_2, std::pair<Point_2, Point_2>>& 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<K> 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<K>(snapped_graph);
}
@@ -2239,7 +2247,9 @@ extend_end_vertices_based_on_input_simple(
DebugWriter& debug_output,
const Graph2D<K>& 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<Point_2>{};
};
@@ -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<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) {
std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right, Logger& logger) {
using Walk_pl = CGAL::Arr_walk_along_line_point_location<Arrangement_2>;
Walk_pl walk_pl(right);
@@ -2610,7 +2630,7 @@ std::vector<K::FT> 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<K::FT> 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<K::FT> 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<double>;
CGAL::Cartesian_converter<K, SK> 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<std::string, std::chrono::high_resolution_clock::time_point>::const_iterator start_it)
: start_it(start_it) {}
entry(
std::map<std::string, std::chrono::high_resolution_clock::time_point>::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<double, std::milli>(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<std::map<std::string, std::chrono::high_resolution_clock::time_point>::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<Polygon_2>& input_polygons_, std::vector<Polygon_2>& output_polygons, double polygon_offset_distance = -1.) {
void arrange_cgal_polygons(
svgfill::arrange_polygon_settings settings,
const std::vector<Polygon_2>& input_polygons_,
std::vector<Polygon_2>& 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<K> 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<svgfill::polygon_2>& polygons, std::vector<svgfill::polygon_2>& arranged) {
bool svgfill::arrange_polygons(
arrange_polygon_settings settings,
const std::vector<svgfill::polygon_2>& polygons,
std::vector<svgfill::polygon_2>& arranged,
Logger& logger) {
std::vector<Polygon_2> 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<Polygon_2> 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<K>(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;
}
+3 -1
View File
@@ -40,6 +40,8 @@
#include <array>
#include <vector>
class Logger;
namespace svgfill {
typedef std::array<double, 2> point_2;
typedef std::array<point_2, 2> 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<polygon_2>& polygons, std::vector<polygon_2>& arranged);
SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector<polygon_2>& polygons, std::vector<polygon_2>& arranged, Logger& logger);
}
#endif