diff --git a/.github/workflows/ci-ifcwrap-standalone.yml b/.github/workflows/ci-ifcwrap-standalone.yml new file mode 100644 index 0000000000..9bd901791b --- /dev/null +++ b/.github/workflows/ci-ifcwrap-standalone.yml @@ -0,0 +1,155 @@ +# This file was generated with the assistance of an AI coding tool. +name: ci-ifcwrap-standalone + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/ci-ifcwrap-standalone.yml" + - "cmake/**" + - "src/ifcwrap/**" + - "src/ifcparse/**" + - "src/ifcgeom/**" + - "src/serializers/**" + - "src/ifcconvert/**" + - "src/ifcopenshell-python/**" + - "src/svgfill/**" + push: + paths: + - ".github/workflows/ci-ifcwrap-standalone.yml" + - "cmake/**" + - "src/ifcwrap/**" + - "src/ifcparse/**" + - "src/ifcgeom/**" + - "src/serializers/**" + - "src/ifcconvert/**" + - "src/ifcopenshell-python/**" + - "src/svgfill/**" + +env: + IFCOPENSHELL_PREFIX: ${{ github.workspace }}/ifcopenshell-install + +jobs: + build-ifcopenshell: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install C++ dependencies + run: | + sudo apt update + sudo apt-get install --no-install-recommends -y \ + cmake \ + gcc \ + g++ \ + libboost-date-time-dev \ + libboost-filesystem-dev \ + libboost-iostreams-dev \ + libboost-program-options-dev \ + libboost-regex-dev \ + libboost-system-dev \ + libboost-thread-dev \ + libeigen3-dev \ + libocct-data-exchange-dev \ + libocct-draw-dev \ + libocct-foundation-dev \ + libocct-modeling-algorithms-dev \ + libocct-modeling-data-dev \ + libocct-ocaf-dev \ + libocct-visualization-dev \ + libpcre3-dev \ + libtbb-dev \ + libxml2-dev \ + libxi-dev \ + occt-misc \ + tcl-dev \ + tk-dev \ + swig + + - name: Configure minimal IfcOpenShell + run: | + cmake -S cmake -B build-ifcopenshell \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${IFCOPENSHELL_PREFIX}" \ + -DCMAKE_PREFIX_PATH=/usr \ + -DCMAKE_SYSTEM_PREFIX_PATH=/usr \ + -DMINIMAL_BUILD=ON \ + -DBUILD_IFCPYTHON=OFF \ + "-DSCHEMA_VERSIONS=4x3_add2" + + - name: Build and install minimal IfcOpenShell + run: | + cmake --build build-ifcopenshell --target install -j "$(nproc)" + + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: 3.11 + + - name: Install Python import dependencies + run: | + python -m pip install --upgrade pip + python -m pip install numpy typing_extensions + + - name: Configure standalone IfcPython + run: | + PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')" + PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')" + + cmake -S src/ifcwrap -B "build-ifcwrap-311" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \ + -DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \ + -DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" + + - name: Build and install standalone IfcPython + run: | + cmake --build "build-ifcwrap-311" --target install -j "$(nproc)" + + - name: Import installed IfcPython + run: | + PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY' + import ifcopenshell + + print("IfcOpenShell import ok:", ifcopenshell.version) + PY + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: 3.12 + + - name: Install Python import dependencies + run: | + python -m pip install --upgrade pip + python -m pip install numpy typing_extensions + + - name: Configure standalone IfcPython + run: | + PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')" + PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')" + + cmake -S src/ifcwrap -B "build-ifcwrap-312" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \ + -DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \ + -DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" + + - name: Build and install standalone IfcPython + run: | + cmake --build "build-ifcwrap-312" --target install -j "$(nproc)" + + - name: Import installed IfcPython + run: | + PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY' + import ifcopenshell + + print("IfcOpenShell import ok:", ifcopenshell.version) + PY diff --git a/cmake/FindIfcOpenShell.cmake b/cmake/FindIfcOpenShell.cmake new file mode 100644 index 0000000000..22e04ef5b8 --- /dev/null +++ b/cmake/FindIfcOpenShell.cmake @@ -0,0 +1,131 @@ +# This file was generated with the assistance of an AI coding tool. +################################################################################ +# # +# This file is part of IfcOpenShell. # +# # +# IfcOpenShell is free software: you can redistribute it and/or modify # +# it under the terms of the Lesser GNU General Public License as published by # +# the Free Software Foundation, either version 3.0 of the License, or # +# (at your option) any later version. # +# # +# IfcOpenShell is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# Lesser GNU General Public License for more details. # +# # +# You should have received a copy of the Lesser GNU General Public License # +# along with this program. If not, see . # +# # +################################################################################ + +include("${CMAKE_CURRENT_LIST_DIR}/utilities.cmake" OPTIONAL) + +set(_IfcOpenShell_find_args) +if(IfcOpenShell_FIND_VERSION) + list(APPEND _IfcOpenShell_find_args "${IfcOpenShell_FIND_VERSION}") + if(IfcOpenShell_FIND_VERSION_EXACT) + list(APPEND _IfcOpenShell_find_args EXACT) + endif() +endif() +list(APPEND _IfcOpenShell_find_args CONFIG QUIET) +if(IfcOpenShell_FIND_COMPONENTS) + list(APPEND _IfcOpenShell_find_args COMPONENTS ${IfcOpenShell_FIND_COMPONENTS}) +endif() + +set(_IfcOpenShell_saved_module_path "${CMAKE_MODULE_PATH}") +list(REMOVE_ITEM CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}") +find_package(IfcOpenShell ${_IfcOpenShell_find_args}) +set(CMAKE_MODULE_PATH "${_IfcOpenShell_saved_module_path}") + +if(NOT IfcOpenShell_FOUND) + set(_IfcOpenShell_error "Could not find an IfcOpenShell CMake config package. Set IfcOpenShell_DIR or CMAKE_PREFIX_PATH.") + if(IfcOpenShell_FIND_REQUIRED) + message(FATAL_ERROR "${_IfcOpenShell_error}") + elseif(NOT IfcOpenShell_FIND_QUIETLY) + message(STATUS "${_IfcOpenShell_error}") + endif() + return() +endif() + +set(_IfcOpenShell_required_targets IfcOpenShell::IfcParse IfcOpenShell::IfcGeom) +set(_IfcOpenShell_missing_targets "") +foreach(_IfcOpenShell_target IN LISTS _IfcOpenShell_required_targets) + if(NOT TARGET ${_IfcOpenShell_target}) + list(APPEND _IfcOpenShell_missing_targets ${_IfcOpenShell_target}) + endif() +endforeach() + +if(_IfcOpenShell_missing_targets) + set(IfcOpenShell_FOUND FALSE) + string(REPLACE ";" ", " _IfcOpenShell_missing_targets_text "${_IfcOpenShell_missing_targets}") + set(_IfcOpenShell_error "IfcOpenShell config was found, but required targets are missing: ${_IfcOpenShell_missing_targets_text}.") + if(IfcOpenShell_FIND_REQUIRED) + message(FATAL_ERROR "${_IfcOpenShell_error}") + elseif(NOT IfcOpenShell_FIND_QUIETLY) + message(STATUS "${_IfcOpenShell_error}") + endif() + return() +endif() + +if(NOT DEFINED IFCOPENSHELL_WITH_OPENCASCADE) + set(IFCOPENSHELL_WITH_OPENCASCADE OFF) + if(TARGET IfcOpenShell::geometry_kernel_opencascade) + set(IFCOPENSHELL_WITH_OPENCASCADE ON) + endif() +endif() + +if(NOT DEFINED IFCOPENSHELL_WITH_CGAL) + set(IFCOPENSHELL_WITH_CGAL OFF) + if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL) + set(IFCOPENSHELL_WITH_CGAL ON) + endif() +endif() + +if(NOT DEFINED IFCOPENSHELL_IFCXML) + set(IFCOPENSHELL_IFCXML OFF) +endif() + +if(NOT DEFINED IFCOPENSHELL_WITH_ROCKSDB) + set(IFCOPENSHELL_WITH_ROCKSDB OFF) +endif() + +set(IFCOPENSHELL_LIBRARIES IfcOpenShell::IfcParse) +foreach(_IfcOpenShell_target IN ITEMS IfcOpenShell::geometry_serializer IfcOpenShell::Serializers) + if(TARGET ${_IfcOpenShell_target}) + list(APPEND IFCOPENSHELL_LIBRARIES ${_IfcOpenShell_target}) + endif() +endforeach() + +set(IFCOPENSHELL_KERNEL_LIBRARIES "") +foreach(_IfcOpenShell_target IN ITEMS + IfcOpenShell::geometry_kernel_opencascade + IfcOpenShell::geometry_kernel_cgal + IfcOpenShell::geometry_kernel_cgal_simple +) + if(TARGET ${_IfcOpenShell_target}) + list(APPEND IFCOPENSHELL_KERNEL_LIBRARIES ${_IfcOpenShell_target}) + endif() +endforeach() + +set(IFCOPENSHELL_GEOMETRY_LIBRARIES IfcOpenShell::IfcGeom ${IFCOPENSHELL_KERNEL_LIBRARIES}) + +if(TARGET IfcOpenShell::OpenCASCADE_INTERFACE) + set(OpenCASCADE_LIBRARIES IfcOpenShell::OpenCASCADE_INTERFACE) +endif() + +if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL) + set(CGAL_LIBRARIES IfcOpenShell::IFCOPENSHELL_CGAL) +endif() + +if(TARGET IfcOpenShell::svgfill) + set(IFCOPENSHELL_SVGFILL_LIBRARY IfcOpenShell::svgfill) +endif() + +mark_as_advanced(IfcOpenShell_DIR) + +unset(_IfcOpenShell_error) +unset(_IfcOpenShell_find_args) +unset(_IfcOpenShell_missing_targets) +unset(_IfcOpenShell_missing_targets_text) +unset(_IfcOpenShell_required_targets) +unset(_IfcOpenShell_target) diff --git a/cmake/IfcOpenShellConfig.cmake.in b/cmake/IfcOpenShellConfig.cmake.in index e5e5d350b8..741e4fa233 100644 --- a/cmake/IfcOpenShellConfig.cmake.in +++ b/cmake/IfcOpenShellConfig.cmake.in @@ -7,12 +7,26 @@ set(IFCOPENSHELL_WITH_OPENCASCADE @WITH_OPENCASCADE@) set(IFCOPENSHELL_WITH_CGAL @WITH_CGAL@) set(IFCOPENSHELL_IFCXML @IFCXML_SUPPORT@) set(IFCOPENSHELL_WITH_ROCKSDB @WITH_ROCKSDB@) +set(IFCOPENSHELL_COLLADA_SUPPORT @COLLADA_SUPPORT@) +set(IFCOPENSHELL_GLTF_SUPPORT @GLTF_SUPPORT@) +set(IFCOPENSHELL_HDF5_SUPPORT @HDF5_SUPPORT@) +set(IFCOPENSHELL_WITH_PROJ @WITH_PROJ@) +set(IFCOPENSHELL_USD_SUPPORT @USD_SUPPORT@) include(CMakeFindDependencyMacro) -set(Boost_USE_STATIC_LIBS ON) -set(Boost_USE_STATIC_RUNTIME OFF) -set(Boost_USE_MULTITHREADED ON) +set(IFCOPENSHELL_BOOST_USE_STATIC_LIBS "@Boost_USE_STATIC_LIBS@") +set(IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME "@Boost_USE_STATIC_RUNTIME@") +set(IFCOPENSHELL_BOOST_USE_MULTITHREADED "@Boost_USE_MULTITHREADED@") +if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}" STREQUAL "") + set(Boost_USE_STATIC_LIBS ${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}) +endif() +if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}" STREQUAL "") + set(Boost_USE_STATIC_RUNTIME ${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}) +endif() +if(NOT "${IFCOPENSHELL_BOOST_USE_MULTITHREADED}" STREQUAL "") + set(Boost_USE_MULTITHREADED ${IFCOPENSHELL_BOOST_USE_MULTITHREADED}) +endif() set(Boost_COMPONENTS system program_options @@ -43,13 +57,33 @@ if(IFCOPENSHELL_WITH_ROCKSDB) endif() if(IFCOPENSHELL_IFCXML) - find_dependency(LibXml2 CONFIG) + find_dependency(LibXml2) endif() if(IFCOPENSHELL_WITH_CGAL) find_dependency(CGAL CONFIG) endif() +if(IFCOPENSHELL_COLLADA_SUPPORT) + find_dependency(OpenCOLLADA) +endif() + +if(IFCOPENSHELL_GLTF_SUPPORT) + find_dependency(nlohmann_json CONFIG) +endif() + +if(IFCOPENSHELL_HDF5_SUPPORT) + find_dependency(HDF5 COMPONENTS C CXX) +endif() + +if(IFCOPENSHELL_WITH_PROJ) + find_dependency(PROJ) +endif() + +if(IFCOPENSHELL_USD_SUPPORT) + find_dependency(USD) +endif() + if(IFCOPENSHELL_WITH_OPENCASCADE) find_dependency(OpenCASCADE CONFIG) if(OpenCASCADE_VERSION VERSION_LESS "7.7.0") diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 6fc51c463a..c3324178df 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -106,7 +106,7 @@ endif endif # def PLATFORM # Current build commit hash. -OLD:=1c5b825 +OLD:=3e7b739 .PHONY: bump bump: ifndef NEW diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index fab7646162..4556a379d0 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -90,6 +90,7 @@ modules = { "web": None, "light": None, "alignment": None, + "clip_box": None, # Uncomment this line to enable loading of the demo module. Happy hacking! # The name "demo" must correlate to a folder name in `bim/module/`. # "demo": None, diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index ca355cc8fa..81d577026a 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -223,6 +223,7 @@ class IfcImporter: self.elements: set[ifcopenshell.entity_instance] = set() self.annotations: set[ifcopenshell.entity_instance] = set() self.gross_elements: set[ifcopenshell.entity_instance] = set() + self.broken_arrays: set[ifcopenshell.entity_instance] = set() self.element_types: set[ifcopenshell.entity_instance] = set() self.spatial_elements: set[ifcopenshell.entity_instance] = set() self.meshes: dict[str, OBJECT_DATA_TYPE] = {} @@ -1220,7 +1221,17 @@ class IfcImporter: continue for i in range(len(data)): tool.Array.set_children_lock_state(element, i, True) - tool.Array.constrain_children_to_parent(element) + tool.Array.constrain_children_to_parent(element) + for layer in data: + for child_guid in layer.get("children", ()): + try: + self.file.by_guid(child_guid) + except RuntimeError: + print( + f"setup_arrays: array parent {element.GlobalId} references missing " + f"child GUID {child_guid!r}." + ) + self.broken_arrays.add(element) def update_linked_aggregates(self): # TODO Remove this after a while. See commit 17d6b8a diff --git a/src/bonsai/bonsai/bim/module/cad/operator.py b/src/bonsai/bonsai/bim/module/cad/operator.py index bef1d851af..5d74857822 100644 --- a/src/bonsai/bonsai/bim/module/cad/operator.py +++ b/src/bonsai/bonsai/bim/module/cad/operator.py @@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator): class CadOffset(bpy.types.Operator): bl_idname = "bim.cad_offset" bl_label = "CAD Offset" - bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle." + bl_description = ( + "Offset selected mesh geometry at provided distance, based on the current viewport angle. " + "Creates a copy by default, or moves the existing edges if Copy is disabled." + ) bl_options = {"REGISTER", "UNDO"} distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE") + copy: bpy.props.BoolProperty( + name="Copy", + description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location", + default=True, + ) @classmethod def poll(cls, context): @@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator): rotation = Matrix.Rotation(pi / 2, 2, "Z") rotation_i = Matrix.Rotation(-pi / 2, 2, "Z") + # When not copying, the offset positions are gathered here and applied to + # the existing verts only after all loops are processed, so that the + # original coordinates are still available while computing offsets. + moved_verts = [] + # Create loops from edges loop_edges = set(edges) loops = [] @@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator): offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2) offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d()) new_vert = v1.co + offset - new_verts.append(bm.verts.new(new_vert)) else: normal = (normals[0] * self.distance).to_3d() offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal) new_vert = v1.co + offset + + if self.copy: new_verts.append(bm.verts.new(new_vert)) + else: + moved_verts.append((v1, new_vert)) processed_verts.add(v1.index) @@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator): v1 = v2 - [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - if is_closed: - bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) + if self.copy: + [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + if is_closed: + bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) + + # Move the existing edges to the offset location. + for vert, new_co in moved_verts: + vert.co = new_co bm.verts.index_update() bm.edges.index_update() diff --git a/src/bonsai/bonsai/bim/module/cad/prop.py b/src/bonsai/bonsai/bim/module/cad/prop.py index 7dab36df91..aee4b9d55f 100644 --- a/src/bonsai/bonsai/bim/module/cad/prop.py +++ b/src/bonsai/bonsai/bim/module/cad/prop.py @@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup): resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1) radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE") distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE") + copy: bpy.props.BoolProperty( + name="Copy", + description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location", + default=True, + ) x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE") y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE") gable_roof_edge_angle: bpy.props.FloatProperty( @@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup): resolution: int radius: float distance: float + copy: bool x: float y: float gable_roof_edge_angle: float diff --git a/src/bonsai/bonsai/bim/module/cad/workspace.py b/src/bonsai/bonsai/bim/module/cad/workspace.py index 24fb98ba3f..270c7f70b9 100644 --- a/src/bonsai/bonsai/bim/module/cad/workspace.py +++ b/src/bonsai/bonsai/bim/module/cad/workspace.py @@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator): elif self.hotkey == "S_O": row = self.layout.row() row.prop(props, "distance") + row = self.layout.row() + row.prop(props, "copy") elif self.hotkey == "S_R": if tool.Geometry.is_profile_object_active(): @@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator): bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius) def hotkey_S_O(self): - bpy.ops.bim.cad_offset(distance=self.props.distance) + bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy) def hotkey_S_Q(self): obj = bpy.context.active_object diff --git a/src/bonsai/bonsai/bim/module/clip_box/__init__.py b/src/bonsai/bonsai/bim/module/clip_box/__init__.py new file mode 100644 index 0000000000..baebdd48f1 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/__init__.py @@ -0,0 +1,98 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import bpy +from bpy.app.handlers import persistent + +import bonsai.tool as tool + +from . import operator, prop, ui + +classes = ( + operator.BIM_OT_add_clip_box, + operator.BIM_OT_duplicate_clip_box, + operator.BIM_OT_remove_clip_box, + operator.BIM_OT_set_active_clip_box, + operator.BIM_OT_toggle_clip_box_enabled, + prop.BIMClipBoxProperties, + prop.BIMSceneClipBoxProperties, + ui.BIM_UL_clip_box, + ui.BIM_PT_clip_box, +) + + +@persistent +def _on_depsgraph_update(scene, depsgraph): + tool.ClipBox.on_depsgraph_update(scene, depsgraph) + tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph) + + +@persistent +def _on_load_post(filepath): + # Restore the per-scene clip-box list from the project's BBIM_ClipBoxes + # pset. Runs after the standard load_post that creates Blender objects. + tool.ClipBox._last_seen_object_matrices.clear() + tool.ClipBox.load_from_project_pset() + + +_draw_handler_pre = None +_draw_handler_post = None + + +def register(): + global _draw_handler_pre, _draw_handler_post + bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties) + bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties) + tool.ClipBox.reset_ownership() + if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update) + if _on_load_post not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(_on_load_post) + if _draw_handler_pre is None: + _draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW") + if _draw_handler_post is None: + _draw_handler_post = bpy.types.SpaceView3D.draw_handler_add( + tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW" + ) + + +def unregister(): + global _draw_handler_pre, _draw_handler_post + if _draw_handler_post is not None: + try: + bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW") + except ValueError: + pass + _draw_handler_post = None + if _draw_handler_pre is not None: + try: + bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW") + except ValueError: + pass + _draw_handler_pre = None + if _on_load_post in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.remove(_on_load_post) + if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update) + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_seen_object_matrices.clear() + tool.ClipBox.clear_clip_planes() + del bpy.types.Object.BIMClipBoxProperties + del bpy.types.Scene.BIMSceneClipBoxProperties diff --git a/src/bonsai/bonsai/bim/module/clip_box/operator.py b/src/bonsai/bonsai/bim/module/clip_box/operator.py new file mode 100644 index 0000000000..b080daa3de --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/operator.py @@ -0,0 +1,170 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +from __future__ import annotations + +import bpy + +import bonsai.tool as tool + +CLIP_BOX_NAME = "ClipBox" +CLIP_BOX_COLLECTION = "BBIM_ClipBoxes" + + +class BIM_OT_add_clip_box(bpy.types.Operator): + bl_idname = "bim.add_clip_box" + bl_label = "Add Clip Box" + bl_description = ( + "Create a clip box empty at the 3D cursor. The empty's location, rotation, and scale " + "drive the viewport clip planes; resize with S, move with G, rotate with R" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + + obj = bpy.data.objects.new(CLIP_BOX_NAME, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.location = context.scene.cursor.location.copy() + # Default to a 20m cube (scale 10 around [-1, +1] local cube) so + # the volume covers a typical building storey or two rather than + # the meaningless 2m unit cube. The user resizes with S. + obj.scale = (10.0, 10.0, 10.0) + obj.show_in_front = True + + collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) + collection.objects.link(obj) + + obj_props = tool.ClipBox.get_object_props(obj) + obj_props.is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + + # Adding a new clip box arms clipping so the user sees the cut + # immediately. Without this they'd have to find the panel + # toggle to discover the feature actually works. + scene_props.enabled = True + + tool.Blender.set_active_object(obj) + tool.ClipBox.refresh(context.scene) + # Persist to the project pset so the box round-trips through IFC + # save/load. A project-level pset avoids the IfcRoot scale lock / + # strip that a per-entity placement would trigger on export. + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} + + +class BIM_OT_remove_clip_box(bpy.types.Operator): + bl_idname = "bim.remove_clip_box" + bl_label = "Remove Clip Box" + bl_description = "Remove this clip box and its host empty" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + delete_object: bpy.props.BoolProperty(default=True, name="Delete Host Object") + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + index = self.index if self.index >= 0 else scene_props.active_clip_box_index + if index < 0 or index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + + entry = scene_props.clip_boxes[index] + obj = entry.obj + scene_props.clip_boxes.remove(index) + if scene_props.active_clip_box_index >= len(scene_props.clip_boxes): + scene_props.active_clip_box_index = max(0, len(scene_props.clip_boxes) - 1) + + if self.delete_object and obj is not None: + bpy.data.objects.remove(obj, do_unlink=True) + + tool.ClipBox.refresh(context.scene) + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} + + +class BIM_OT_set_active_clip_box(bpy.types.Operator): + bl_idname = "bim.set_active_clip_box" + bl_label = "Set Active Clip Box" + bl_description = "Set this clip box as the active one driving the viewport clip" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + if self.index < 0 or self.index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + scene_props.active_clip_box_index = self.index + return {"FINISHED"} + + +class BIM_OT_toggle_clip_box_enabled(bpy.types.Operator): + bl_idname = "bim.toggle_clip_box_enabled" + bl_label = "Toggle Clip Box" + bl_description = "Toggle whether the active clip box is driving the viewport clip planes" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + scene_props.enabled = not scene_props.enabled + return {"FINISHED"} + + +class BIM_OT_duplicate_clip_box(bpy.types.Operator): + bl_idname = "bim.duplicate_clip_box" + bl_label = "Duplicate Clip Box" + bl_description = "Duplicate this clip box: copy its empty + matrix into a new entry" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + source_index = self.index if self.index >= 0 else scene_props.active_clip_box_index + if source_index < 0 or source_index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + source = scene_props.clip_boxes[source_index].obj + if source is None: + return {"CANCELLED"} + + copy = bpy.data.objects.new(source.name, None) + copy.empty_display_type = source.empty_display_type + copy.empty_display_size = source.empty_display_size + copy.show_in_front = source.show_in_front + copy.matrix_world = source.matrix_world.copy() + + collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) + collection.objects.link(copy) + + tool.ClipBox.get_object_props(copy).is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = copy + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + scene_props.enabled = True + + tool.Blender.set_active_object(copy) + tool.ClipBox.refresh(context.scene) + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py new file mode 100644 index 0000000000..f503d8781f --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -0,0 +1,107 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bpy +from bpy.types import PropertyGroup + +import bonsai.tool as tool +from bonsai.bim.prop import ObjProperty + + +class BIMClipBoxProperties(PropertyGroup): + """Per-object marker for a clip-box host empty. + + The host empty's ``matrix_world`` is the single source of truth for + the clip box's pose and dimensions: translation = box centre, + rotation = box orientation, per-axis scale = world half-extents. The + visible cube comes from the empty's CUBE display. + + Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay + (``show_caps``) are global per-file and live on the Scene PG. + """ + + is_clip_box: bpy.props.BoolProperty( + default=False, + description="True when this empty was created as a clip-box host. Internal flag; not user-edited.", + ) + + if TYPE_CHECKING: + is_clip_box: bool + + +def update_active_clip_box_index(self, context): + tool.ClipBox.schedule_refresh() + tool.ClipBox.select_active_clip_box(context) + + +def update_show_caps(self, context): + tool.ClipBox.schedule_refresh() + + +def update_enabled(self, context): + tool.ClipBox.schedule_refresh() + + +class BIMSceneClipBoxProperties(PropertyGroup): + """Scene-level registry of clip boxes in this file. + + Multiple boxes may exist; ``active_clip_box_index`` selects which one + drives the viewport clip at any time. ``enabled`` and ``show_caps`` + are global because the user's intent ("hide everything outside the + box", "draw cap overlays") applies file-wide, not per box. + + ``enabled`` is intentionally not persisted to the project pset: + opening a fresh IFC should never silently hide geometry behind a + remembered toggle. Selecting any clip-box empty in the viewport + re-arms it (see :meth:`tool.ClipBox._sync_active_to_selection`). + """ + + clip_boxes: bpy.props.CollectionProperty(type=ObjProperty) + active_clip_box_index: bpy.props.IntProperty( + default=0, + min=0, + update=update_active_clip_box_index, + description="Index of the clip box currently driving the viewport clip planes", + ) + enabled: bpy.props.BoolProperty( + name="Enabled", + default=False, + update=update_enabled, + description="When enabled, the active clip box hides all viewport geometry outside its 6 faces", + ) + show_caps: bpy.props.BoolProperty( + name="Show Caps", + default=True, + update=update_show_caps, + description=( + "Draw filled cross-section caps where IFC product geometry " + "crosses the active clip planes. Disable for performance on " + "very heavy scenes" + ), + ) + + if TYPE_CHECKING: + active_clip_box_index: int + enabled: bool + show_caps: bool diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py new file mode 100644 index 0000000000..b5ae95d9e2 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -0,0 +1,86 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +from __future__ import annotations + +from bpy.types import Panel, UIList + +import bonsai.tool as tool + + +class BIM_UL_clip_box(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag): + obj = item.obj + if obj is None: + layout.label(text="(missing)", icon="ERROR") + return + row = layout.row(align=True) + row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE") + row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index + row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index + + +class BIM_PT_clip_box(Panel): + bl_idname = "BIM_PT_clip_box" + bl_label = "Clip Box" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_options = {"DEFAULT_CLOSED"} + bl_parent_id = "BIM_PT_tab_sandbox" + + def draw(self, context): + layout = self.layout + scene_props = tool.ClipBox.get_scene_props(context.scene) + + toggles = layout.row(align=True) + toggles.scale_y = 2.0 + toggles.prop( + scene_props, + "enabled", + text="Enable Clipping", + icon="HIDE_OFF" if scene_props.enabled else "HIDE_ON", + toggle=True, + ) + toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True) + + layout.separator() + layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box") + + layout.template_list( + "BIM_UL_clip_box", + "", + scene_props, + "clip_boxes", + scene_props, + "active_clip_box_index", + rows=3, + ) + + obj = tool.ClipBox.get_active_clip_box(context.scene) + if obj is None: + layout.label(text="No active clip box", icon="INFO") + return + + col = layout.column(align=True) + col.label(text="Edit the empty with G / R / S to move / rotate / resize") + col.prop(obj, "location") + col.prop(obj, "rotation_euler") + col.prop(obj, "scale") diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 3c03e9db49..99db87d08d 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -159,40 +159,13 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL -_BONSAI_TRANSFORM_MACROS = frozenset( - { - # Bonsai overrides Blender's default move/duplicate keymaps with - # macros that wrap TRANSFORM_OT_translate. While a macro is the outer - # modal entry, the inner TRANSFORM_OT_translate does not surface in - # window.modal_operators — the macro's own idname does. The - # ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at - # runtime (the class declaration uses the dotted ``bim.`` form). - "BIM_OT_override_move_macro", # G key - "BIM_OT_override_object_duplicate_move_macro", # Shift+D - "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D - "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D - } -) - - def _is_transform_modal_active(context) -> bool: - """True iff a Blender transform modal (G/R/S and siblings, including - Bonsai's macro overrides) is currently driving per-frame ``matrix_world`` - updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of - running modal operators. Parametric gizmo groups gate poll + draw_prepare - on this so they hide for the duration of the drag instead of sliding - off-cursor as the matrix updates each frame.""" - window = getattr(context, "window", None) - if window is None: - return False - modal_ops = getattr(window, "modal_operators", None) - if not modal_ops: - return False - for op in modal_ops: - idname = op.bl_idname - if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS: - return True - return False + """Module-local alias for ``tool.Blender.is_transform_modal_active``. + + Preserved as a name so AST scans and call sites in this file stay + decoupled from the helper's home module. + """ + return tool.Blender.is_transform_modal_active(context) def _hide_all_non_modal_gizmos(group) -> None: @@ -4877,7 +4850,14 @@ class GizmoDimension(GizmoMovable): self.init_value = click_distance - if self.initial_snap_state and self.active_obj: + # Schematic gizmos opt out of dimension snap. Force the header + # indicator to ``off`` for the drag's duration so the user sees the + # state matches behaviour; ``exit`` restores ``initial_snap_state``. + # Skipping the snap cache here also avoids the per-drag mesh probe. + snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True) + if not snap_supported: + context.scene.tool_settings.use_snap = False + elif self.initial_snap_state and self.active_obj: build_snap_cache(context, self.active_obj) self._snap_cache_built = True @@ -4919,11 +4899,18 @@ class GizmoDimension(GizmoMovable): if not region or not rv3d: return {"RUNNING_MODAL"} - tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state + # Group-level opt-out: schematic gizmos float in viewport space, so + # global-snap-to-scene-vertices would produce spurious value jumps. + # The fallback (``True``) covers any gizmo whose group is not a + # ``BaseParametricGizmoGroup``. + snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True) - if tool_settings.use_snap and not self._snap_cache_built and self.active_obj: - build_snap_cache(context, self.active_obj) - self._snap_cache_built = True + if snap_supported: + tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state + + if tool_settings.use_snap and not self._snap_cache_built and self.active_obj: + build_snap_cache(context, self.active_obj) + self._snap_cache_built = True current_coord = (event.mouse_region_x, event.mouse_region_y) @@ -4947,7 +4934,7 @@ class GizmoDimension(GizmoMovable): delta = (current_3d - self.start_location).dot(axis_direction) - if tool_settings.use_snap and self.active_obj: + if snap_supported and tool_settings.use_snap and self.active_obj: # Snap the dimension tip (not mouse position) to target # Calculate where the dimension tip would be with current delta # The tip is at: gizmo_origin + axis * (init_value + delta) @@ -5320,6 +5307,13 @@ class BaseParametricGizmoGroup: # Pre-computed flip matrix for negative value handling (180° rotation around Z) FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z") + # Default: dimension drags respect Blender's global snap (Ctrl-toggleable + # during drag). Subclasses whose dimensions float in viewport space rather + # than aligning to real-world geometry should override to ``False`` — + # snapping to scene vertices in that case produces spurious value jumps + # as the mouse crosses unrelated meshes. + snap_enabled_on_dimensions: bool = True + # === Icon Gizmo Layout (meters) === # Icons are positioned in a horizontal row above the element: # [Validate] [Cancel] [Cycle] @@ -6580,6 +6574,11 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup): # list and become no-ops. The schematic equivalents below take their place. dimension_gizmo_props: list[DimensionGizmoConfig] = [] + # Schematic dimensions float in billboarded viewport space, not aligned to + # real-world geometry. Snapping the dragged tip to scene vertices would + # produce nonsensical value jumps as the mouse crosses unrelated meshes. + snap_enabled_on_dimensions: bool = False + # Declarative dimension configuration consumed by ``setup_schematic_dimensions`` # and ``update_schematic_dimensions``. Each config produces one # ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index e34b5f7428..9bc0566532 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -890,6 +890,16 @@ class OverrideDelete(bpy.types.Operator): # Track aggregates before deleting their parts aggregates_to_check = self.track_aggregates(objects_to_remove) + # Snapshot the set of IFC entity ids being deleted in this batch so the + # connection-rel cascade inside `delete_ifc_object` can suppress + # partner-side regenerate when the partner is also about to vanish. + batch_being_deleted_ids: set[int] = set() + for obj in objects_to_remove: + if not tool.Blender.is_valid_data_block(obj): + continue + if (entity := tool.Ifc.get_entity(obj)) is not None: + batch_being_deleted_ids.add(entity.id()) + clear_active_object = True for i, obj in enumerate(objects_to_remove, 1): @@ -931,7 +941,7 @@ class OverrideDelete(bpy.types.Operator): if tool.Drawing.is_auto_annotation(element): self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.") continue - tool.Geometry.delete_ifc_object(obj) + tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids) elif tool.Geometry.is_representation_item(obj): tool.Geometry.delete_ifc_item(obj) else: @@ -1030,7 +1040,10 @@ class OverrideDelete(bpy.types.Operator): pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") if not pset: continue - array_parents.add(ifc_file.by_guid(pset["Parent"])) + try: + array_parents.add(ifc_file.by_guid(pset["Parent"])) + except RuntimeError: + continue for array_parent in array_parents: array_parent_obj = tool.Ifc.get_object(array_parent) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index b30fb17896..6583144c89 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -110,6 +110,8 @@ classes = ( wall.GizmoWallFilletPreview, wall.GizmoWallFilletReedit, wall.GizmoWallFilletToggleOpenings, + wall.GizmoSlabEdition, + wall.GizmoSlabUnjoinWalls, wall.GizmoWallJoinIntersection, wall.GizmoWallLinkToggle, wall.GizmoWallUnjoinSingle, @@ -120,7 +122,7 @@ classes = ( wall.RotateWall90, wall.SplitWall, wall.SplitWallAtCursor, - wall.UnjoinWallPathConnection, + wall.DisconnectElements, wall.UnjoinWalls, wall.EnableWallFilletPreview, wall.FinishWallFilletPreview, @@ -154,11 +156,14 @@ classes = ( slab.DisableEditingExtrusionProfile, slab.DisableEditingSketchExtrusionProfile, slab.AddSlabFromWall, + slab.CancelEditingSlab, slab.DrawPolylineSlab, slab.EditExtrusionProfile, slab.EditSketchExtrusionProfile, slab.EnableEditingExtrusionProfile, slab.EnableEditingSketchExtrusionProfile, + slab.EnableEditingSlab, + slab.FinishEditingSlab, slab.RecalculateSlab, slab.ResetVertex, slab.SetArcIndex, @@ -185,6 +190,7 @@ classes = ( prop.BIMDoorProperties, prop.BIMRailingProperties, prop.BIMRoofProperties, + prop.BIMSlabProperties, prop.BIMWallProperties, prop.BIMPipeSegmentProperties, prop.BIMDuctSegmentProperties, @@ -246,9 +252,13 @@ classes = ( railing.CopyRailingParameters, railing.AddRailing, railing.CancelEditingRailing, + railing.CycleRailingType, railing.FinishEditingRailing, + railing.PickRailingTerminalType, railing.FlipRailingPathOrder, railing.EnableEditingRailing, + railing.GizmoRailingSchematic, + railing.ToggleRailingUseManualSupports, railing.CancelEditingRailingPath, railing.FinishEditingRailingPath, railing.EnableEditingRailingPath, diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index bef8f6fe11..956a08462c 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -423,7 +423,11 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): pset = tool.Ifc.get().by_id(pset["id"]) for array in arrays: for child in set(array["children"]): - if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)): + try: + child_element = tool.Ifc.get().by_guid(child) + except RuntimeError: + continue + if child_obj := tool.Ifc.get_object(child_element): tool.Geometry.delete_ifc_object(child_obj) array["children"].clear() # Always operate on the parent — this operator can be invoked with diff --git a/src/bonsai/bonsai/bim/module/model/data.py b/src/bonsai/bonsai/bim/module/model/data.py index 10553f1bed..36cde4eb0d 100644 --- a/src/bonsai/bonsai/bim/module/model/data.py +++ b/src/bonsai/bonsai/bim/module/model/data.py @@ -51,6 +51,10 @@ class AuthoringData: @classmethod def load(cls, ifc_element_type: Optional[str] = None): + # ``is_loaded`` is set first as a recursion guard: one of the data + # computations evaluates a PropertyGroup enum's ``items`` callback, + # which re-enters this method. Without the guard, load recurses to + # RecursionError. cls.is_loaded = True cls.props = tool.Model.get_model_props() cls.data["default_container"] = cls.default_container() diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index b9709c067a..e2f8a2a9a8 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -228,11 +228,7 @@ def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Co def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None: """Regenerate railing mesh when property changes.""" if self.is_editing: - # Only FRAMELESS_PANEL can update live via bmesh. - # WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation, - # so it only updates on "Finish Editing" to avoid modifying IFC during preview. - if self.railing_type == "FRAMELESS_PANEL": - _get_updater("railing", "update_railing_modifier_bmesh")(context) + _get_updater("railing", "update_railing_modifier_bmesh")(context) def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None: @@ -1693,6 +1689,21 @@ class BIMRoofProperties(PropertyGroup): setattr(target_props, prop_name, prop_value) +class BIMSlabProperties(PropertyGroup): + """Transient state for the slab disconnect-access gizmo. + + ``is_editing`` flips True when the user clicks the pen icon on a slab + that has wall connections — gating the per-wall disconnect icons in + ``GizmoSlabUnjoinWalls`` so they're hidden until the user opts in. No + IFC draft state lives here: the disconnect operator commits directly, + so this PropertyGroup carries only the UI gate.""" + + is_editing: bpy.props.BoolProperty(name="Slab Edit Active", default=False, options={"SKIP_SAVE"}) + + if TYPE_CHECKING: + is_editing: bool + + class BIMWallProperties(PropertyGroup): """Transient draft state for parametric wall gizmo editing. diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 6f3697d51d..825cc339ac 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -18,6 +18,7 @@ import json +import math from typing import Any import bmesh @@ -27,14 +28,24 @@ import ifcopenshell.api.geometry import ifcopenshell.api.pset import ifcopenshell.util.representation import ifcopenshell.util.unit -from mathutils import Vector +from mathutils import Matrix, Vector import bonsai.core.geometry import bonsai.core.root import bonsai.tool as tool +from bonsai.bim.module.drawing import gizmos as gizmo +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig +from bonsai.bim.module.model import prop from bonsai.bim.module.model.data import RailingData, refresh from bonsai.bim.module.model.decorator import ProfileDecorator -from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin +from bonsai.bim.parametric_lifecycle import ( + CycleTypeMixin, + PathPreservingEditMixin, + PickTypeMixin, +) +from bonsai.tool.cad import WELD_TOLERANCE + +V_ = tool.Blender.V_ # reference: # https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm @@ -125,6 +136,56 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": railing_data}) +def generate_wall_mounted_handrail_preview( + obj: bpy.types.Object, + props: "BIMRailingProperties", + path_data: dict[str, Any], + si_conversion: float, +) -> None: + """Viewport-only WALL_MOUNTED_HANDRAIL preview: rebuild ``obj.data`` from the same + geometry helper the IFC representation builder uses, without writing any IFC.""" + railing_path = [Vector(v) * si_conversion for v in path_data["verts"]] + looped_path = path_data["edges"][-1][-1] == path_data["edges"][0][0] + + geom = ifcopenshell.api.geometry.compute_wall_mounted_handrail_geometry( + railing_path=railing_path, + support_spacing=props.support_spacing, + railing_diameter=props.railing_diameter, + clear_width=props.clear_width, + height=props.height, + use_manual_supports=props.use_manual_supports, + terminal_type=props.terminal_type, + looped_path=looped_path, + unit_scale=1.0, # props are already SI; bypass the IFC project-units conversion + ) + + bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True) + + tool.Cad.sweep_disk_along_polyline( + bm, + [Vector(p) for p in geom.handrail_polyline], + geom.handrail_radius, + arc_indices=geom.handrail_arc_point_indices, + ) + + for support in geom.supports: + tool.Cad.sweep_disk_along_polyline( + bm, + [Vector(p) for p in support.arc_polyline], + support.arc_radius, + ) + tool.Cad.add_disk_extrusion( + bm, + Vector(support.disk_position), + support.disk_radius, + support.disk_depth, + support.disk_z_rotation, + ) + + bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) + tool.Blender.apply_bmesh(obj.data, bm) + + def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: """before using should make sure that Data contains up-to-date information. If BBIM Pset just changed should call refresh() before updating bmesh @@ -140,6 +201,13 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: path_data = RailingData.data["path_data"] si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + # WALL_MOUNTED_HANDRAIL renders the preview from the compute helper; IFC stays + # untouched until Finish Editing rebuilds the representation. + if not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL": + generate_wall_mounted_handrail_preview(obj, props, path_data, si_conversion) + return + # need to make sure we support edit mode # since users will probably be in edit mode when they'll be changing railing path bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True) @@ -165,8 +233,6 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: thickness = props.thickness spacing = props.spacing - # spacing - # split each edge in 3 segments by 0.5 * spacing by x-y plane main_edges = bm.edges[:] for main_edge in main_edges: bm_split_edge_at_offset(main_edge, spacing) @@ -211,7 +277,7 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve) bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve) # to remove unnecessary verts in 0 spacing case - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE) bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) @@ -271,8 +337,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]: segments.append((i - 1, 0)) break - # skip path verts if they just go vertical to avoid errors - if (v.co.xy - prev_v.co.xy).length <= 0.0001: + # Vertical-only segments project to a degenerate XY edge; skip to avoid divide-by-zero downstream. + if (v.co.xy - prev_v.co.xy).length <= WELD_TOLERANCE: continue points.append(v.co) @@ -407,9 +473,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator): class _RailingEditMixin(PathPreservingEditMixin): - """Type-specific hooks for railing parametric-edit operators. Single-object - (active_object). ``path_data`` is preserved through the edit; the separate - ``Enable/Finish/CancelEditingRailingPath`` operators handle path editing.""" + """Single-object (active_object) railing-edit hooks; path_data is preserved + through the edit (path editing is a separate operator family).""" pset_name = "BBIM_Railing" @@ -436,7 +501,21 @@ class _RailingEditMixin(PathPreservingEditMixin): update_railing_modifier_ifc_data(context) @classmethod - def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """WALL_MOUNTED_HANDRAIL reloads the committed Body; others rebuild the preview bmesh.""" + props = tool.Model.get_railing_props(obj) + if props.railing_type == "WALL_MOUNTED_HANDRAIL": + element = tool.Ifc.get_entity(obj) + assert element + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if body: + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=body, + ) + return update_railing_modifier_bmesh(context) @@ -467,6 +546,554 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera return self._finish_targets(context) +class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin): + """Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses.""" + + bl_idname = "bim.cycle_railing_type" + bl_label = "Cycle Railing Type" + bl_options = {"REGISTER", "UNDO"} + + element_checker = tool.Parametric.is_railing + props_getter = tool.Model.get_railing_props + type_literal = tool.Model.RailingType + type_attr = "railing_type" + + def _execute(self, context: bpy.types.Context) -> set[str]: + return self._cycle_type(context) + + +class ToggleRailingUseManualSupports(bpy.types.Operator): + """Flip use_manual_supports on the active WALL_MOUNTED_HANDRAIL railing. + + No-op unless a parametric edit is active and the railing is wall-mounted. + """ + + bl_idname = "bim.toggle_railing_use_manual_supports" + bl_label = "Toggle Railing Manual Supports" + bl_description = "Switch between automatic support spacing and manual per-vertex placement" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + resolved = tool.Model.resolve_active_props_for_edit( + context, + tool.Model.get_railing_props, + subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"), + ) + if resolved is None: + return {"CANCELLED"} + _obj, props = resolved + props.use_manual_supports = not props.use_manual_supports + return {"FINISHED"} + + +class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin): + """Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing.""" + + bl_idname = "bim.pick_railing_terminal_type" + bl_label = "Pick Railing Terminal Type" + bl_description = "Pick the cap geometry applied at the rail ends" + bl_options = {"REGISTER", "UNDO"} + + skip_element_check = True + props_getter = tool.Model.get_railing_props + type_literal = prop.CapType + type_attr = "terminal_type" + + def _execute(self, context: bpy.types.Context) -> set[str]: + if ( + tool.Model.resolve_active_props_for_edit( + context, + tool.Model.get_railing_props, + subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"), + ) + is None + ): + return {"CANCELLED"} + return self._pick_type(context) + + +def _format_attr_distance(attr_name: str): + """text_formatter that renders the named property as a distance, ignoring the + dimension's visible-length argument (which is fixed for schematic gizmos).""" + return lambda p, _v: tool.Unit.format_distance(getattr(p, attr_name)) + + +class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup): + """Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode.""" + + bl_idname = "OBJECT_GGT_bim_railing_edition" + bl_label = "Railing Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_railing" + finish_editing_operator = "bim.finish_editing_railing" + cancel_editing_operator = "bim.cancel_editing_railing" + cycle_type_operator = "bim.cycle_railing_type" + + props_getter = tool.Model.get_railing_props + gizmo_pref_name = "railing" + + # Schematic-local layout. +X → screen RIGHT, +Y → screen UP, +Z → toward viewer + # (post billboard rotation). Each dimension is anchored alongside the feature it + # measures so the label, not the bar length, carries the value. + SCHEMATIC_MESH_HEIGHT_FRAC = 0.9 # Mesh top edge in schematic-local +Y + SCHEMATIC_MESH_WIDTH_FRAC = 0.7 # Mesh side edges in schematic-local ±X + SCHEMATIC_MESH_RAIL_Y_FRAC = SCHEMATIC_MESH_HEIGHT_FRAC / 2 # WALL_MOUNTED_HANDRAIL rail centreline + SCHEMATIC_MESH_DEPTH_FRAC = 0.06 # Panel depth — small so the schematic reads as slabs not boxes + # WALL_MOUNTED_HANDRAIL dimensions — fractions of schematic_box_size so they + # scale with the host group's box size. + SCHEMATIC_RAIL_RADIUS_FRAC = 0.05 + SCHEMATIC_RAIL_CLEAR_FRAC = 0.5 # Stylised — wider than real-world for visible bracket arm + SCHEMATIC_RAIL_INSET_FRAC = 0.08 # Wall extends past the outermost support on both sides + + @classmethod + def schematic_rail_radius(cls) -> float: + return cls.schematic_box_size * cls.SCHEMATIC_RAIL_RADIUS_FRAC + + @classmethod + def schematic_rail_clear(cls) -> float: + return cls.schematic_box_size * cls.SCHEMATIC_RAIL_CLEAR_FRAC + + # Axonometric 3/4 view: +Z projects down-and-left so the depth axis + # is visibly separated from the back face. Without the X tilt, panel + # thickness (schematic-local Z) collapses to a near-horizontal bar. + schematic_view_rotation = Matrix.Rotation(math.radians(20), 4, "X") @ Matrix.Rotation(math.radians(-25), 4, "Y") + + # Hover a dimension → highlight the schematic edges tagged with the matching feature. + # Tags are written by the mesh builders. "spacing" is empty space (no edges) so it's + # absent from this map and gracefully no-ops on hover. + schematic_attr_to_feature = { + "height": "panel_height", + "thickness": "panel_thickness", + "railing_diameter": "rail_tube", + "clear_width": "bracket", + "support_spacing": "bracket", + } + + schematic_dimension_props = [ + # ── FRAMELESS_PANEL ───────────────────────────────────────────── + DimensionGizmoConfig( + attr_name="height", + axis=(0, 1, 0), + min_value=0.01, + # Gated to FRAMELESS_PANEL: in WALL_MOUNTED_HANDRAIL, height only + # feeds TO_FLOOR / TO_END_POST_AND_FLOOR terminals so dragging it + # is a no-op under the default "180" terminal. + visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL", + matrix_position=lambda p: Vector((-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.08, 0.0, 0.0)), + schematic_visible_length=SCHEMATIC_MESH_HEIGHT_FRAC, + text_formatter=_format_attr_distance("height"), + ), + DimensionGizmoConfig( + attr_name="thickness", + axis=(0, 0, 1), # panel depth — projects to a true depth direction under the 3/4 tilt + min_value=0.005, + visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL", + matrix_position=lambda p: Vector( + ( + ( + -GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 + - GizmoRailingSchematic.SCHEMATIC_MESH_GAP_HALF_WIDTH + ) + / 2, + GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC + 0.05, + -GizmoRailingSchematic.SCHEMATIC_MESH_DEPTH_FRAC / 2, + ) + ), + schematic_visible_length=0.4, # longer than default to survive depth foreshortening + text_formatter=_format_attr_distance("thickness"), + ), + DimensionGizmoConfig( + attr_name="spacing", + axis=(1, 0, 0), + min_value=0.0, # zero-spacing collapses the picket gap into a single continuous panel + visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL", + matrix_position=lambda p: Vector((0.0, -0.1, 0.0)), + text_formatter=_format_attr_distance("spacing"), + ), + # ── WALL_MOUNTED_HANDRAIL ────────────────────────────────────── + DimensionGizmoConfig( + attr_name="railing_diameter", + axis=(0, 1, 0), + min_value=0.001, + visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL", + matrix_position=lambda p: Vector( + ( + -GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.05, + GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC - 0.09, + GizmoRailingSchematic.schematic_rail_clear(), + ) + ), + text_formatter=_format_attr_distance("railing_diameter"), + ), + DimensionGizmoConfig( + attr_name="clear_width", + axis=(0, 0, 1), # +Z is the wall-to-rail perpendicular axis under the 3/4 tilt + min_value=0.001, + visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL", + matrix_position=lambda p: Vector( + ( + 0.0, + GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC, + 0.0, + ) + ), + schematic_visible_length=0.36, # 2× default so the call-out survives depth projection + text_formatter=_format_attr_distance("clear_width"), + ), + DimensionGizmoConfig( + attr_name="support_spacing", + axis=(1, 0, 0), + min_value=0.05, + visibility_condition=lambda p: (p.railing_type == "WALL_MOUNTED_HANDRAIL" and not p.use_manual_supports), + matrix_position=lambda p: Vector( + ( + -GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 + + GizmoRailingSchematic.SCHEMATIC_RAIL_INSET_FRAC, + -0.18, + 0.0, + ) + ), + # Bare names (not Gizmo…SCHEMATIC_…) because the class is still under construction here. + schematic_visible_length=SCHEMATIC_MESH_WIDTH_FRAC - 2 * SCHEMATIC_RAIL_INSET_FRAC, + text_formatter=_format_attr_distance("support_spacing"), + ), + ] + + @classmethod + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: + return tool.Parametric.is_railing(element) + + @classmethod + def schematic_cache_key(cls, props) -> tuple: + """Cache the schematic mesh by ``railing_type`` — proportions are fixed + per type, so the bmesh build runs at most twice across a session + (once for ``FRAMELESS_PANEL``, once for ``WALL_MOUNTED_HANDRAIL``) + rather than once per draw call.""" + return (props.railing_type,) + + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Create the WALL_MOUNTED_HANDRAIL-only affordances on the schematic. + + Two static lock glyphs (open/closed) for toggling + ``use_manual_supports``: instantiate both and let the per-frame state + query pick which one to show. State-aware icons use a static pair + rather than a single dynamic gizmo to avoid ``prop_path`` resolution + in the render path. + + Plus a cycle-glyph at the rail end that opens the ``terminal_type`` + popup when clicked. + """ + default_color, highlight_color = self.get_decoration_colors() + + self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair( + "bim.toggle_railing_use_manual_supports", + open_color=default_color, + ) + + self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu") + self.terminal_gizmo.color = default_color + self.terminal_gizmo.color_highlight = highlight_color + self.terminal_gizmo.use_draw_scale = False + self.terminal_gizmo.alpha = 0.8 + self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type") + + def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: + """Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos. + + - Lock glyphs: only WALL_MOUNTED_HANDRAIL while editing. Show + ``lock_open`` when ``use_manual_supports`` is True, the closed + padlock when False ("auto-spacing is locked to support_spacing"). + - Terminal gizmo: same gating, positioned just past the right rail + end so it reads as "configure the rail's end cap". + """ + super()._refresh_element_specific(context, mw, props) + + # ``draw_prepare`` can fire on a freshly recreated GizmoGroup instance + # before ``setup_element_specific_gizmos`` has populated the lock / + # terminal attributes (Blender 5.x recreates per-region groups on + # reload). Bail out cheaply; the next refresh after setup completes + # will reposition them correctly. + if not hasattr(self, "lock_open_gizmo"): + return + + # Single gate for all WALL_MOUNTED_HANDRAIL extras. + active = props.is_editing and not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL" + + if not active: + self.lock_open_gizmo.hide = True + self.lock_closed_gizmo.hide = True + self.terminal_gizmo.hide = True + return + + billboard_rot = self._frame_billboard_rot + view_rotation = self.schematic_view_rotation + anchor = self._compute_schematic_anchor(props, mw, billboard_rot) + + # ── Lock glyphs for use_manual_supports ────────────────────────── + # Sit just above the wall's bottom line, near the centre of the + # schematic — visually grouped with the dimension it controls + # (support_spacing) without overlapping the arrow tail below. + is_manual = bool(props.use_manual_supports) + self.lock_open_gizmo.hide = not is_manual + self.lock_closed_gizmo.hide = is_manual + lock_local = Vector((0.0, 0.05, 0.0)) + lock_world = anchor + billboard_rot @ view_rotation @ lock_local + lock_matrix = gizmo.billboarded_at(lock_world, billboard_rot, 0.09) + self.lock_open_gizmo.matrix_basis = lock_matrix + self.lock_closed_gizmo.matrix_basis = lock_matrix + + # ── Terminal-type popup gizmo at the right rail end ────────────── + # Pushed well past the right wall edge so the icon doesn't crowd + # the wall outline or the bracket attach point. At rail height and + # rail depth so it reads as "attached to the rail terminal". + self.terminal_gizmo.hide = False + terminal_local = Vector( + ( + self.SCHEMATIC_MESH_WIDTH_FRAC / 2 + 0.25, + self.SCHEMATIC_MESH_RAIL_Y_FRAC, + self.schematic_rail_clear(), + ) + ) + terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local + self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18) + + def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None: + """Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon. + + The base class shows the pen gizmo whenever ``is_editing`` is False, + which is the case during path-edit too. Allowing the user to click + through into parametric edit while the polyline mesh is open in EDIT + mode mixes two distinct editing states and leaves a stale draft if + they cancel out — block the entry point instead. The operator itself + is intentionally not guarded (callers via scripting can still invoke + it); this is the UX-level enforcement. + + The cycle icon defaults to the editing icon row (next to validate / + cancel) via the parent's positioning. We move it to just above the + schematic mesh so it reads as "cycle the railing type *shown here*" + — associated with the preview the user is interacting with, not a + generic editing button at the bottom of the schematic. + """ + super().update_editing_gizmos(context, mw, props) + if props.is_editing_path: + self.pen_gizmo.hide = True + + if props.is_editing and not props.is_editing_path: + billboard_rot = self._frame_billboard_rot + view_rotation = self.schematic_view_rotation + anchor = self._compute_schematic_anchor(props, mw, billboard_rot) + # Comfortably above the mesh top edge so the icon doesn't crowd + # the ``thickness`` / ``clear_width`` dimension callouts that + # already sit just above the panel/wall. + cycle_local = Vector((0.0, self.SCHEMATIC_MESH_HEIGHT_FRAC + 0.25, 0.0)) + world_pos = anchor + billboard_rot @ view_rotation @ cycle_local + # 30% smaller than the editing-icon-row default (0.30 → 0.21): + # the cycle is a tertiary affordance compared to pen/validate/cancel. + self.cycle_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, 0.21) + + @classmethod + def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh": + """Build a wireframe preview of the railing in schematic-local coordinates. + + FRAMELESS_PANEL renders as a box whose proportions track the bound + properties (height / thickness / spacing); WALL_MOUNTED_HANDRAIL + renders as a horizontal tube with two L-shaped supports whose + proportions track railing_diameter / clear_width / support_spacing. + Both are scaled to fit inside ``[-schematic_box_size, +schematic_box_size]`` + on each axis so the schematic reads the same regardless of absolute + property values. + + The mesh is decorative — clicks land on the labeled sliders, not on + the preview geometry. See ``BaseSchematicGizmoGroup`` for the + draw-handler lifecycle. + """ + bm = bmesh.new() + if props.railing_type == "FRAMELESS_PANEL": + cls._build_frameless_panel_schematic(bm, props) + else: + cls._build_wall_mounted_handrail_schematic(bm, props) + return bm + + # Schematic-local half-width of the visible gap between the two panel boxes. + # Conveys the "spacing" semantic at a glance — the user sees two pickets + # separated by air, with the spacing dimension emerging from that gap. + SCHEMATIC_MESH_GAP_HALF_WIDTH = 0.05 + + @classmethod + def _build_frameless_panel_schematic(cls, bm: "bmesh.types.BMesh", props) -> None: + """Stylised panel: two wireframe boxes with a visible gap between them. + + The box edges sit at the ``SCHEMATIC_MESH_*_FRAC`` positions + (matching where the dimension gizmos anchor), so each dimension line + visually starts at the geometry feature it measures. Internal + proportions are stable across drags — the actual values are shown + through the dimension labels, while the schematic communicates + which feature each label refers to. The gap between the two boxes + (set by ``SCHEMATIC_MESH_GAP_HALF_WIDTH``) gives the "spacing" + dimension a real visual referent. + + Edges are tagged on a string layer so hover-highlight can colour + the geometric feature being measured: vertical edges → height, + depth edges → thickness. The X-aligned edges along the panel + width are untagged (they don't correspond to a single dimension). + """ + hw = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2 + hd = cls.SCHEMATIC_MESH_DEPTH_FRAC / 2 + h_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC + gap = cls.SCHEMATIC_MESH_GAP_HALF_WIDTH + + layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME + feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name) + + # Edge index → feature tag for one box. Order matches the (a, b) + # tuple order below: bottom ring (4) + top ring (4) + verticals (4). + edge_tags_per_box = ( + b"", # (0,1) bottom-back, X-aligned + b"panel_thickness", # (1,2) bottom-right, Z-aligned + b"", # (2,3) bottom-front, X-aligned + b"panel_thickness", # (3,0) bottom-left, Z-aligned + b"", # (4,5) top-back, X-aligned + b"panel_thickness", # (5,6) top-right, Z-aligned + b"", # (6,7) top-front, X-aligned + b"panel_thickness", # (7,4) top-left, Z-aligned + b"panel_height", # (0,4) vertical back-left + b"panel_height", # (1,5) vertical back-right + b"panel_height", # (2,6) vertical front-right + b"panel_height", # (3,7) vertical front-left + ) + + # Build two separate wireframe boxes — one on each side of the central + # gap. The boxes share the same Y range (0..h_top) and Z range (±hd) + # but split the X range so the gap from -gap to +gap stays empty. + for x_left, x_right in ((-hw, -gap), (gap, hw)): + corners = [ + bm.verts.new((x_left, 0.0, -hd)), + bm.verts.new((x_right, 0.0, -hd)), + bm.verts.new((x_right, 0.0, hd)), + bm.verts.new((x_left, 0.0, hd)), + bm.verts.new((x_left, h_top, -hd)), + bm.verts.new((x_right, h_top, -hd)), + bm.verts.new((x_right, h_top, hd)), + bm.verts.new((x_left, h_top, hd)), + ] + for tag, (a, b) in zip( + edge_tags_per_box, + ( + (0, 1), + (1, 2), + (2, 3), + (3, 0), # bottom ring + (4, 5), + (5, 6), + (6, 7), + (7, 4), # top ring + (0, 4), + (1, 5), + (2, 6), + (3, 7), # vertical edges + ), + ): + edge = bm.edges.new((corners[a], corners[b])) + if tag: + edge[feat_layer] = tag + + @classmethod + def _build_wall_mounted_handrail_schematic(cls, bm: "bmesh.types.BMesh", props) -> None: + """Stylised wall-mounted handrail: wall outline, hex tube, two L-brackets. + + Three visual elements convey "rail mounted on a wall": + + - **Wall outline** — a wireframe rectangle in the YZ plane at ``z=0``, + extending slightly past the rail ends so the wall reads as a + surface the rail is *attached to* rather than a coincident frame. + - **Handrail tube** — a hexagonal cross-section extruded along ±X + at ``z=+clear_s`` (in front of the wall), at ``y=rail_y``. + - **L-shaped brackets** at each rail end — from the rail centreline + drop a short distance, then run perpendicular back to the wall + plane. Mirrors the standard wall-mount bracket geometry: a + horizontal arm holding the rail off the wall, a vertical drop + attaching to the rail. + + Like ``_build_frameless_panel_schematic``, the schematic uses fixed + proportions so the dimension gizmos' anchor points stay aligned + with the geometry features regardless of property values. + """ + half_len = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2 + wall_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC + rail_y = cls.SCHEMATIC_MESH_RAIL_Y_FRAC # rail sits at half wall height + radius_s = cls.schematic_rail_radius() + clear_s = cls.schematic_rail_clear() + + layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME + feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name) + + # ── Wall outline (rectangle at z=0, slightly wider than the rail) ── + # Spans the full schematic height; the rail attaches in the middle, + # so the wall reads as "continuing past the rail above and below". + # Wall edges stay untagged — they're background context, not a + # feature any dimension measures. + wall_extra = 0.08 + wall_x_left = -half_len - wall_extra + wall_x_right = half_len + wall_extra + wall_corners = [ + bm.verts.new((wall_x_left, 0.0, 0.0)), + bm.verts.new((wall_x_right, 0.0, 0.0)), + bm.verts.new((wall_x_right, wall_top, 0.0)), + bm.verts.new((wall_x_left, wall_top, 0.0)), + ] + for a, b in ((0, 1), (1, 2), (2, 3), (3, 0)): + bm.edges.new((wall_corners[a], wall_corners[b])) + + # ── Handrail tube (hex cross-section in YZ, extruded along X) ────── + # Centred on the rail centreline at (±(half_len - rail_inset), + # rail_y, +clear_s) — in front of the wall plane at z=0. The tube + # is shorter than the wall so the wall visibly extends past it on + # both sides; the L-brackets sit at the tube ends, so the leftmost + # bracket no longer coincides with the wall's left edge. + rail_inset = cls.SCHEMATIC_RAIL_INSET_FRAC + rail_x_left = -half_len + rail_inset + rail_x_right = half_len - rail_inset + segments = 6 + ring_left, ring_right = [], [] + for i in range(segments): + theta = 2 * math.pi * i / segments + dy = math.cos(theta) * radius_s + dz = math.sin(theta) * radius_s + ring_left.append(bm.verts.new((rail_x_left, rail_y + dy, clear_s + dz))) + ring_right.append(bm.verts.new((rail_x_right, rail_y + dy, clear_s + dz))) + # All hex-tube edges tagged "rail_tube" so they highlight together + # when the railing_diameter dimension is hovered. + for i in range(segments): + j = (i + 1) % segments + e_left = bm.edges.new((ring_left[i], ring_left[j])) + e_right = bm.edges.new((ring_right[i], ring_right[j])) + e_axial = bm.edges.new((ring_left[i], ring_right[i])) + e_left[feat_layer] = b"rail_tube" + e_right[feat_layer] = b"rail_tube" + e_axial[feat_layer] = b"rail_tube" + + # ── L-brackets at each rail end (rail → drop → wall) ─────────────── + # Bracket attach points follow the rail ends, so they're pulled + # inward by ``rail_inset`` from the wall edges. From the rail + # centreline, drop ``bracket_drop`` in Y, then run perpendicular + # back to the wall plane (z=0). The L shape reads as a wall-mount + # bracket under the 3/4 tilt. Both bracket segments tagged + # "bracket" so they highlight when clear_width OR support_spacing + # is hovered (both dimensions measure features of the supports). + bracket_drop = 0.06 + for x in (rail_x_left, rail_x_right): + v_rail = bm.verts.new((x, rail_y, clear_s)) + v_corner = bm.verts.new((x, rail_y - bracket_drop, clear_s)) + v_wall = bm.verts.new((x, rail_y - bracket_drop, 0.0)) + e1 = bm.edges.new((v_rail, v_corner)) + e2 = bm.edges.new((v_corner, v_wall)) + e1[feat_layer] = b"bracket" + e2[feat_layer] = b"bracket" + + class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.flip_railing_path_order" bl_label = "Flip Railing Path Order" @@ -510,6 +1137,16 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator): [o.select_set(False) for o in context.selected_objects if o != obj] assert obj props = tool.Model.get_railing_props(obj) + + # Auto-commit any in-progress parametric draft before switching to + # path-edit. ``set_props_kwargs_from_ifc_data`` a few lines below + # overwrites props with the pset's stored values — without committing + # first, anything the user dragged on a dimension gizmo (height, + # diameter, …) would be silently discarded the moment path-edit + # starts. + if props.is_editing: + tool.Parametric.commit_object_draft(obj, "bim.finish_editing_railing") + data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"] # required since we could load pset from .ifc and BIMRoofProperties won't be set props.set_props_kwargs_from_ifc_data(data) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 58a353ab28..9d7e88a23d 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -271,7 +271,7 @@ class DumbSlabPlaner: # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation. # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach. - existing_x_angle = obj.rotation_euler.x + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle @@ -991,3 +991,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator): tool.Model.recalculate_walls(walls) return {"FINISHED"} + + +class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Open the slab disconnect-access mode. Pure UI toggle: flips + ``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect + gizmos surface on the slab. ``tool.Ifc.Operator`` base because the + parametric framework's universal dispatcher routes through + ``tool.Parametric.run_bim_op``, which only accepts that subclass for + undo-safe lifecycle. No IFC mutation.""" + + bl_idname = "bim.enable_editing_slab" + bl_label = "Edit Slab Connections" + bl_description = "Show disconnect icons for every wall clipped to this slab" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = True + return {"FINISHED"} + + +class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Close the slab disconnect-access mode.""" + + bl_idname = "bim.cancel_editing_slab" + bl_label = "Close Slab Edit" + bl_description = "Hide the slab disconnect icons" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = False + return {"FINISHED"} + + +class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Close the slab disconnect-access mode. Same body as Cancel — slab + edit is a pure UI gate with no IFC draft to commit; the framework + requires both ``bim.finish_editing_`` and + ``bim.cancel_editing_`` to exist by name convention.""" + + bl_idname = "bim.finish_editing_slab" + bl_label = "Finish Slab Edit" + bl_description = "Hide the slab disconnect icons" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = False + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index dd9d21697c..ecbe544eb9 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -48,6 +48,7 @@ import mathutils.geometry import numpy as np from mathutils import Matrix, Vector +import bonsai.core.connection import bonsai.core.geometry import bonsai.core.model as core import bonsai.core.root @@ -108,6 +109,50 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool: return True +def _resolve_active_partner_pair( + context: bpy.types.Context, +) -> "tuple[bpy.types.Object, bpy.types.Object, ifcopenshell.entity_instance, ifcopenshell.entity_instance] | None": + """Return ``(active_obj, partner_obj, active_elem, partner_elem)`` for a + selection of exactly two IFC-bound objects with the active one named, + else ``None``. Used by every 2-selection gizmo to skip the standard + "resolve active + partner + IFC entities" preamble.""" + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return None + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return None + partner = next((o for o in selected if o != active), None) + if partner is None: + return None + active_elem = tool.Ifc.get_entity(active) + partner_elem = tool.Ifc.get_entity(partner) + if active_elem is None or partner_elem is None: + return None + return active, partner, active_elem, partner_elem + + +def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_editing: bool = False) -> bool: + """Shared gate for slab-side connection gizmos: exactly 1 IfcSlab + selected, not an array child, has at least one wall clipped to its + underside. With ``require_editing=True`` additionally requires the + slab's parametric edit lifecycle to be active (pen icon clicked) so + the gizmo only surfaces after explicit opt-in.""" + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + if len(tool.Blender.get_selected_objects()) != 1: + return False + element = tool.Ifc.get_entity(active) + if element is None or not element.is_a("IfcSlab"): + return False + if tool.Blender.Modifier.any_selected_is_array_child(): + return False + if require_editing and not tool.Model.get_slab_props(active).is_editing: + return False + return any(tool.Wall.iter_slab_wall_connections(element)) + + def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool: """Tighter gate for wall topology gizmos (merge / join / extend / unjoin / fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter. @@ -246,6 +291,15 @@ def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> N _maybe_resync_wall_props_from_ifc(obj) +def _regenerate_walls(objs: "Iterable[bpy.types.Object | None]") -> None: + """Rebuild every wall in ``objs`` from current IFC state — extrusion, + openings, and any underside slab clip — so the caller doesn't carry + feature-specific dispatch.""" + for obj in objs: + if obj is not None: + tool.Model.regenerate_wall(obj) + + class _CommitWallDraftsFirstMixin: """Operator mixin that flushes any in-progress wall parametric drafts in the current selection before delegating to the subclass's ``_perform``. @@ -285,19 +339,29 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper _resync_walls_after_mutation(tool.Blender.get_selected_objects()) -class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): - """Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one - specific partner wall, leaving the active wall's other connections intact. The - partner is identified by IFC GlobalId — invariant under Blender-object renames, - file save/reload, and the undo stack — set on the operator properties by the - single-wall unjoin gizmo at click time.""" +class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): + """Disconnect two IFC elements given their GlobalIds — generic dispatcher + that infers the connection rel kind via tool.Connection.find_rels and runs + the right post-disconnect cleanup: - bl_idname = "bim.unjoin_wall_path_connection" - bl_label = "Unjoin Wall Connection" - bl_description = "Disconnect the active wall from a single specific partner wall" + - ``"path"`` (IfcRelConnectsPathElements) → removes every rel between + the pair (catches both orientations) via remove_connection + recreates + both walls + resyncs drafts. + - ``"element-top"`` (IfcRelConnectsElements with Description=="TOP") → + disconnect_element + regenerate_wall_to_underside on the wall side. + - ``"element"`` (other IfcRelConnectsElements) → disconnect_element only. + + Both endpoints by GlobalId so the dispatch survives rename / undo / save. + Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab + operators with one entry-point gizmos and shortcuts can bind to.""" + + bl_idname = "bim.disconnect_elements" + bl_label = "Disconnect Elements" + bl_description = "Remove the connection between two IFC elements identified by GlobalId" bl_options = {"REGISTER", "UNDO"} - other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId") + element_a_guid: bpy.props.StringProperty(name="Element A GlobalId") + element_b_guid: bpy.props.StringProperty(name="Element B GlobalId") @classmethod def poll(cls, context): @@ -309,44 +373,54 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, return True def _perform(self, context): - active = tool.Blender.get_active_object(is_selected=True) - if not active: - self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") + ifc_file = tool.Ifc.get() + try: + elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None + elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None + except RuntimeError: + elem_a = elem_b = None + if elem_a is None or elem_b is None: + self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.") return - elem_active = tool.Ifc.get_entity(active) - if not elem_active: - self.report({"ERROR"}, "Active object is not bound to an IFC entity.") + rels = tool.Connection.find_rels(elem_a, elem_b) + if not rels: + self.report({"ERROR"}, "No connection found between elements.") return - elem_other = None - if self.other_wall_guid: - try: - elem_other = tool.Ifc.get().by_guid(self.other_wall_guid) - except RuntimeError: - elem_other = None - other = tool.Ifc.get_object(elem_other) if elem_other else None - if not elem_other or not other: - self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") + # The fillet corner's join with its source walls defines the fillet's + # identity — unjoining there would tear down the chord axis reference + # without rebuilding the source walls' miter cuts. Deleting the corner + # wall is the supported teardown, which cascades back to the source + # walls via the connection-cleanup handler. + either_is_fillet = tool.Parametric.is_fillet_corner_wall(elem_a) or tool.Parametric.is_fillet_corner_wall( + elem_b + ) + if either_is_fillet and any(k == "path" for _, k in rels): + self.report( + {"INFO"}, + "Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.", + ) return - # Walk the inverse graph for the specific IfcRelConnectsPathElements joining - # these two walls and remove only that one. `disconnect_path`'s - # (relating, related) mode only inspects `relating.ConnectedTo`, so a single - # call misses the rel when it was authored with the opposite orientation. - rels = [ - rel - for rel in getattr(elem_active, "ConnectedTo", []) - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other - ] + [ - rel - for rel in getattr(elem_active, "ConnectedFrom", []) - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other - ] - for rel in rels: - bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) - # Recreate body+axis on both walls so the mesh state matches the IFC mutation - # and stale miter cuts are dropped. - tool.Model.recreate_wall(elem_active, active) - tool.Model.recreate_wall(elem_other, other) - _resync_walls_after_mutation([active, other]) + path_objs: list[bpy.types.Object] = [] + for rel, kind in rels: + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=rel, + kind=kind, + elem=elem_a, + partner=elem_b, + ) + if kind == "path": + obj_a = tool.Ifc.get_object(elem_a) + obj_b = tool.Ifc.get_object(elem_b) + if obj_a is not None and obj_a not in path_objs: + path_objs.append(obj_a) + if obj_b is not None and obj_b not in path_objs: + path_objs.append(obj_b) + if path_objs: + _resync_walls_after_mutation(path_objs) class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): @@ -369,7 +443,7 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to element = tool.Ifc.get_entity(obj) if not element: continue - if tool.Model.get_usage_type(element) == "LAYER2": + if tool.Parametric.is_path_connectable_wall(element): walls.append(obj) else: slabs.append(obj) @@ -390,7 +464,7 @@ class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator): wall_objs = [ obj for obj in tool.Blender.get_selected_objects() - if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2" + if (element := tool.Ifc.get_entity(obj)) and tool.Parametric.is_path_connectable_wall(element) ] if wall_objs: core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs) @@ -642,9 +716,14 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat def _perform(self, context): selected_objs = tool.Model.get_selected_mesh_objects() + post_split_walls: list[bpy.types.Object] = [] for obj in selected_objs: - DumbWallJoiner().split(obj, context.scene.cursor.location) - _resync_walls_after_mutation(selected_objs) + new_obj = DumbWallJoiner().split(obj, context.scene.cursor.location) + post_split_walls.append(obj) + if new_obj is not None and new_obj not in post_split_walls: + post_split_walls.append(new_obj) + _resync_walls_after_mutation(post_split_walls) + _regenerate_walls(post_split_walls) return {"FINISHED"} @@ -674,11 +753,13 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat active_obj = context.active_object assert active_obj selected_objs = tool.Model.get_selected_mesh_objects() - # The merge deletes the second argument when the walls are collinear; - # only the first survives, so the resync targets the non-active wall. - surviving_obj = next(o for o in selected_objs if o != active_obj) - DumbWallJoiner().merge(surviving_obj, active_obj) - _maybe_resync_wall_props_from_ifc(surviving_obj) + # Active-is-survivor — matches Blender's Ctrl+J / "merge at last" + # convention. The first argument survives, the second is consumed, + # so the active wall ends up absorbing the other. + other_obj = next(o for o in selected_objs if o != active_obj) + DumbWallJoiner().merge(active_obj, other_obj) + _maybe_resync_wall_props_from_ifc(active_obj) + _regenerate_walls([active_obj]) return {"FINISHED"} @@ -745,6 +826,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): if layer2_objs: tool.Model.recalculate_walls(layer2_objs) + _resync_walls_after_mutation(layer2_objs) return {"FINISHED"} @@ -791,7 +873,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): extrusion.Depth = perpendicular_depth else: if tool.Model.get_usage_type(element) == "LAYER3": - existing_x_angle = obj.rotation_euler.x + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle @@ -860,6 +942,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): if layer2_objs: tool.Model.recalculate_walls(layer2_objs) + _resync_walls_after_mutation(layer2_objs) return {"FINISHED"} @@ -882,6 +965,7 @@ class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator): selected_objs = tool.Model.get_selected_mesh_ifc_objects() for obj in selected_objs: joiner.set_length(obj, self.length) + _resync_walls_after_mutation(selected_objs) class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator): @@ -1463,7 +1547,7 @@ class DumbWallJoiner: body = copy.deepcopy(axis1["reference"]) tool.Model.recreate_wall(element1, wall1) - def split(self, wall1: bpy.types.Object, target: Vector) -> None: + def split(self, wall1: bpy.types.Object, target: Vector) -> "bpy.types.Object | None": unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) element1 = tool.Ifc.get_entity(wall1) @@ -1484,6 +1568,13 @@ class DumbWallJoiner: wall2 = self.duplicate_wall(wall1) element2 = tool.Ifc.get_entity(wall2) + # The duplicate inherits wall1's slab-trim boolean chain (copied by + # copy_class) but ``BBIM_Boolean.Data`` carries wall1's stale ids, so + # ``get_manual_booleans(element2)`` returns empty and the regenerator + # rebuilds wall2's body without those clips. Strip them up front so + # wall2 starts clean before the axis + placement reshape. + tool.Model.strip_underside_booleans(element2) + # Get the ATEND connection from wall1 to use it in wall2 relating_element = None connections = element1.ConnectedTo @@ -1543,13 +1634,16 @@ class DumbWallJoiner: r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings ]: rel = opening.HasFillings[0] - filling = rel.RelatedBuildingElement - filling_obj = tool.Ifc.get_object(filling) - filling_location = filling_obj.matrix_world.translation - _, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d) min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale) + # Use the opening's axis-projected midpoint to classify the side. + # The filling's ``matrix_world.translation`` is flip-fragile — + # flipping rotates the filler 180° + translates so the bbox + # stays visually in place, moving the door origin to the + # opposite corner, which would mis-classify a flipped door + # centred over the cut. + opening_midpoint = (min_t + max_t) / 2 void_straddles = min_t < cut_percentage < max_t - if filling_position > cut_percentage: + if opening_midpoint > cut_percentage: # The filling should be moved from element1 to element2. new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening) new_opening.VoidsElements[0].RelatingBuildingElement = element2 @@ -1564,13 +1658,16 @@ class DumbWallJoiner: rel.RelatingOpeningElement = new_opening - # Remove the old opening - ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) - if void_straddles: # Filling moved to element2, but void straddles — add a - # pure-void copy back to element1 so its body still gets cut. - _add_void_copy(element1, new_opening) + # pure-void copy back to element1. Read from the original + # ``opening`` whose ObjectPlacement still references + # element1; ``new_opening`` was rebound to element2 and + # would copy element2's frame instead. + _add_void_copy(element1, opening) + + # Remove the old opening + ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) elif void_straddles: # Filling stays on element1, but void straddles — add a pure-void # copy to element2 so its body gets cut. @@ -1583,6 +1680,7 @@ class DumbWallJoiner: tool.Model.recreate_wall(element1, wall1) tool.Model.recreate_wall(element2, wall2) + return wall2 def flip(self, wall1: bpy.types.Object) -> None: if tool.Ifc.is_moved(wall1): @@ -1638,7 +1736,14 @@ class DumbWallJoiner: p2[0] = max(x_ordinates) self.set_axis(element1, p1, p2) + # ConnectedTo / ConnectedFrom carry both ``IfcRelConnectsPathElements`` + # (the wall-wall joins this loop migrates) and + # ``IfcRelConnectsElements`` (the slab underside clip). Only the + # path rels expose ``RelatingConnectionType`` / ``RelatedConnectionType``; + # the element rels die with element2 via the trailing cascade delete. for rel in element2.ConnectedTo: + if not rel.is_a("IfcRelConnectsPathElements"): + continue ifcopenshell.api.geometry.disconnect_path( tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType ) @@ -1651,6 +1756,8 @@ class DumbWallJoiner: ) for rel in element2.ConnectedFrom: + if not rel.is_a("IfcRelConnectsPathElements"): + continue ifcopenshell.api.geometry.disconnect_path( tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType ) @@ -1662,6 +1769,26 @@ class DumbWallJoiner: related_connection=rel.RelatedConnectionType, ) + # Re-host openings from the discarded wall to the survivor before + # the cascade delete tears down element2's voids and any filling + # that depends on them. ``edit_object_placement`` preserves the + # opening's world position when element1 and element2 have + # different placements — a ``PlacementRelTo`` swap alone would + # shift the opening as the relative offset changes. + ifc_file = tool.Ifc.get() + for rel in list(element2.HasOpenings): + opening = rel.RelatedOpeningElement + rel.RelatingBuildingElement = element1 + if opening.ObjectPlacement: + world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement) + ifcopenshell.api.geometry.edit_object_placement( + ifc_file, + product=opening, + matrix=world_matrix, + is_si=False, + should_transform_children=False, + ) + tool.Model.recreate_wall(element1, wall1) tool.Geometry.delete_ifc_object(wall2) @@ -2458,7 +2585,9 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator): tool.Model, context.scene.cursor.location, ) - _resync_walls_after_mutation(tool.Blender.get_selected_objects()) + affected = list(tool.Blender.get_selected_objects()) + _resync_walls_after_mutation(affected) + _regenerate_walls(affected) return {"FINISHED"} @@ -2491,6 +2620,7 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator): with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): bpy.ops.bim.change_extrusion_depth(depth=new_height) _maybe_resync_wall_props_from_ifc(obj) + _regenerate_walls([obj]) return {"FINISHED"} @@ -3117,6 +3247,12 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp # the banana body. If a neighbour moved, the new placement follows; if # neither moved, the new matrix equals the old within floating-point noise. _apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj) + # The body rebuild swaps the wall's representation, so any prior underside + # clip is gone. Re-clip from the surviving TOP rels so an extend-to-slab + # applied to a fillet wall isn't silently wiped on the next neighbour + # recalc, ChangeExtrusionDepth, or split / merge call site. + if tool.Model.has_underside_connection(element): + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [obj]) class EnableWallFilletPreview(bpy.types.Operator): @@ -3236,6 +3372,19 @@ class CancelWallFilletPreview(bpy.types.Operator): props = preview_base.get_preview_props(context, "wall_fillet") if props is None or not props.is_active: return {"CANCELLED"} + # Clear the corner's edit flag so the connection disconnect gizmos + # disappear in lockstep with the radius preview when the user + # cancels. The id read happens BEFORE clear_preview_state wipes it. + corner_id = props.editing_corner_id + if corner_id: + ifc_file = tool.Ifc.get() + if ifc_file is not None: + try: + corner_obj = tool.Ifc.get_object(ifc_file.by_id(corner_id)) + except RuntimeError: + corner_obj = None + if corner_obj is not None: + tool.Model.get_wall_props(corner_obj).is_editing = False preview_base.clear_preview_state(props) return {"FINISHED"} @@ -3309,6 +3458,11 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator): props.radius = float(radius) props.editing_corner_id = corner_elem.id() props.is_active = True + # Flag the corner as "in edit mode" so the wall-side connection + # disconnect gizmos surface in parallel with the fillet preview — + # one pen-icon click enters BOTH radius retune AND connection + # inspection. + tool.Model.get_wall_props(corner_obj).is_editing = True return {"FINISHED"} @@ -3589,7 +3743,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin return False other = next(o for o in selected if o is not active) other_element = tool.Ifc.get_entity(other) - if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2": + if not other_element or not tool.Parametric.is_path_connectable_wall(other_element): return False return True @@ -3855,15 +4009,17 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo): class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): """Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at - every join location inferred from the wall's IfcRelConnectsPathElements inverse - graph — the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall - unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND - by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated - and hidden on a per-frame basis based on the live connection set. + every connection location on the wall — wall-wall path connections via + IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements + with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART + + 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped + slab), so a pool of icons is preallocated and hidden on a per-frame basis based + on the live connection set. - Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner - wall's GlobalId set on the bound operator properties, so a click removes only - the single rel under that icon — the other connections on the same wall survive. + Each visible icon dispatches `bim.disconnect_elements` with the active wall + + partner element GlobalIds set on the bound operator properties, so a click + removes only the single rel under that icon — the other connections on the + same wall survive. Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group requires len(selected) == 2; this one requires 1).""" @@ -3881,10 +4037,25 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix # creation is forbidden — so the pool must be sized upfront for the worst case. POOL_SIZE = 16 ICON_SCALE = 0.35 + SLAB_STACK_MAX = 5 + SLAB_STACK_OFFSET_Z = 0.5 + # Muted gray used for connection icons that are visible (the connection + # exists) but inert (clicking dispatches a no-op + INFO report). Fillet + # corner ↔ source-wall joins use this — disconnecting them would tear + # down the fillet's chord axis reference, so the supported teardown is + # deleting the corner wall instead. + LOCKED_COLOR: ClassVar[tuple[float, float, float]] = (0.5, 0.5, 0.5) @classmethod def poll(cls, context: bpy.types.Context) -> bool: - if not _wall_topology_gizmo_poll_gate(context): + # Bypass the shared topology gate's ``any_preview_active`` block — + # ``BIMWallProperties.is_editing`` is the real gate for this gizmo + # group, and that flag is set both by the regular wall edit lifecycle + # AND by the fillet preview entry (so a fillet corner under preview + # surfaces its connections in parallel with the radius drag). + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + if tool.Blender.Modifier.any_selected_is_array_child(): return False active = tool.Blender.get_active_object(is_selected=True) if active is None: @@ -3902,6 +4073,9 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix def setup(self, context: bpy.types.Context) -> None: default_color, highlight_color = self.get_decoration_colors() + # Stashed so per-frame ``_bind_unjoin_icon`` can restore the active + # tone when an icon was muted in a previous frame for fillet lock. + self._default_unjoin_color = default_color # Bind the operator on each pool icon ONCE at setup time and keep the returned # OperatorProperties handles. target_set_operator allocates a fresh handle on # every call, so calling it from position_gizmos (which fires every redraw @@ -3911,11 +4085,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix self.unjoin_op_props = [] for _ in range(self.POOL_SIZE): icon = self.setup_icon_gizmo( - "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection" + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" ) icon.hide = True self.unjoin_icons.append(icon) - self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection")) + self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements")) def position_gizmos(self, context: bpy.types.Context) -> None: # Default: hide every pool slot. The visible-set is rebuilt from the live @@ -3936,15 +4110,28 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix billboard_rot = gizmo.get_billboard_rotation(context) clearance = gizmo.top_down_clearance(context, billboard_rot) - connections = _get_wall_connections_cached(self, elem) - if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + path_connections = _get_wall_connections_cached(self, elem) + slab_connections = list(tool.Wall.iter_wall_slab_connections(elem)) + slab_overflow = max(0, len(slab_connections) - self.SLAB_STACK_MAX) + if slab_overflow and not getattr(self, "_slab_cap_warned", False): print( - f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; " + f"[bonsai] GizmoWallUnjoinSingle: wall has {len(slab_connections)} slab " + f"connections; only the first {self.SLAB_STACK_MAX} are shown stacked." + ) + self._slab_cap_warned = True + slab_connections = slab_connections[: self.SLAB_STACK_MAX] + total = len(path_connections) + len(slab_connections) + if total > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoWallUnjoinSingle: wall has {total} connections " + f"({len(path_connections)} path + {len(slab_connections)} slab); " f"only the first {self.POOL_SIZE} unjoin gizmos are shown." ) self._pool_cap_warned = True - for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections): + slot_idx = 0 + self_is_fillet = tool.Parametric.is_fillet_corner_wall(elem) + for other_elem, self_ct, other_ct in path_connections: if slot_idx >= self.POOL_SIZE: break other_obj = tool.Ifc.get_object(other_elem) @@ -3955,20 +4142,224 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix continue seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom) location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct) + is_locked = self_is_fillet or tool.Parametric.is_fillet_corner_wall(other_elem) + self._bind_unjoin_icon( + slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj, is_locked=is_locked + ) + slot_idx += 1 + + for stack_idx, (slab_elem, _rel) in enumerate(slab_connections): + if slot_idx >= self.POOL_SIZE: + break + slab_obj = tool.Ifc.get_object(slab_elem) + if slab_obj is None: + continue + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + continue + # Stack vertically so each slab gets a distinct clickable icon; + # hover-highlight then shows the user which slab they're about to + # disconnect from. + stacked = location + Vector((0.0, 0.0, stack_idx * self.SLAB_STACK_OFFSET_Z)) + self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj) + slot_idx += 1 + + def _bind_unjoin_icon( + self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj, *, is_locked=False + ): + """Place + bind one pool icon to a (active, partner) GlobalId pair. + + Only the GlobalId properties are rewritten per frame; the operator + binding itself is the long-lived handle set up at setup() time. GlobalId + (not Blender object name) keeps the binding stable across renames, file + save/reload, and any sit-in-the-undo-stack interlude between dispatch + and execute. The partner Blender object is mirrored onto the icon for + its hover-outline draw, since the Gizmo API exposes + ``target_set_operator`` but no symmetric reader. + + ``is_locked=True`` (fillet corner involvement) writes a muted color + instead of the active tone; the GUIDs still propagate so the bound + operator can surface a friendly INFO report on click.""" + icon = self.unjoin_icons[slot_idx] + icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE) + icon.hide = False + icon.color = self.LOCKED_COLOR if is_locked else self._default_unjoin_color + self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId + icon.partner_obj = partner_obj + + +class GizmoSlabUnjoinWalls(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Slab-side mirror of GizmoWallUnjoinSingle: when exactly one IfcSlab is + selected and at least one wall is clipped to its underside, surface an + unjoin icon at each connection point. The icons resolve at the same + world location as the wall-side gizmo (via the symmetric + tool.Wall.wall_slab_connection_location_world) so the same connection + has a single visual marker reachable from either selection. + + Each visible icon dispatches bim.disconnect_elements with the slab + + wall GlobalIds, so a click removes the single rel under that icon and + re-clips the wall to whatever remaining slabs it's connected to.""" + + bl_idname = "OBJECT_GGT_bim_slab_unjoin_walls" + bl_label = "Slab Unjoin Walls Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + POOL_SIZE = 16 + ICON_SCALE = 0.35 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return _slab_connection_gizmo_poll_gate(context, require_editing=True) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.unjoin_icons = [] + self.unjoin_op_props = [] + for _ in range(self.POOL_SIZE): + icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" + ) + icon.hide = True + self.unjoin_icons.append(icon) + self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements")) + + def position_gizmos(self, context: bpy.types.Context) -> None: + for icon in self.unjoin_icons: + icon.hide = True + + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return + slab_obj = selected[0] + slab_elem = tool.Ifc.get_entity(slab_obj) + if slab_elem is None: + return + + billboard_rot = gizmo.get_billboard_rotation(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + connections = list(tool.Wall.iter_slab_wall_connections(slab_elem)) + if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoSlabUnjoinWalls: slab has {len(connections)} wall connections; " + f"only the first {self.POOL_SIZE} unjoin gizmos are shown." + ) + self._pool_cap_warned = True + + slot_idx = 0 + for wall_elem, _rel in connections: + if slot_idx >= self.POOL_SIZE: + break + wall_obj = tool.Ifc.get_object(wall_elem) + if wall_obj is None: + continue + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + continue icon = self.unjoin_icons[slot_idx] icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE) icon.hide = False - # Only the partner-GlobalId property is rewritten per frame; the operator - # binding itself is the long-lived handle set up at setup() time. GlobalId - # (not Blender object name) keeps the binding stable across renames, file - # save/reload, and any sit-in-the-undo-stack interlude between dispatch - # and execute. - self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId - # Mirror the partner reference onto the icon itself so its draw() - # can outline the partner on hover without a Gizmo-side getter on - # the bound operator (the API exposes target_set_operator with - # no symmetric reader). - icon.partner_obj = other_obj + self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId + icon.partner_obj = wall_obj + slot_idx += 1 + + +class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + """Pen / validate / cancel triad for slab disconnect-access mode. + + Polls on a single IfcSlab with at least one wall clipped to its underside. + Pen routes through the universal ``bim.enable_editing_parametric`` + dispatcher; finish + cancel both clear ``is_editing`` (no IFC mutation — + the framework requires the triad to exist by name convention even for a + pure UI gate). ESC, the red-coloured cancel icon, mutual exclusion with + other active parametric edits, gizmo prefs gating — all handled by the + base class.""" + + bl_idname = "OBJECT_GGT_bim_slab_edition" + bl_label = "Slab Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_slab" + finish_editing_operator = "bim.finish_editing_slab" + cancel_editing_operator = "bim.cancel_editing_slab" + cycle_type_operator = "" + + props_getter = tool.Model.get_slab_props + gizmo_pref_name = "slab" + + @classmethod + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: + return tool.Parametric.is_slab(element) and any(tool.Wall.iter_slab_wall_connections(element)) + + +class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Surfaces a disconnect icon when exactly 2 IFC elements are selected + and they share a supported rel — currently the wall + slab pair joined + by an ``IfcRelConnectsElements(TOP)``. Click dispatches + ``bim.disconnect_elements`` with both GlobalIds. For wall-wall pairs, + ``GizmoWallJoinIntersection``'s unjoin icon already exposes the same + affordance via ``bim.unjoin_walls``.""" + + bl_idname = "OBJECT_GGT_bim_pair_disconnect" + bl_label = "Disconnect Pair Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_SCALE = 0.35 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return False + if tool.Blender.Modifier.any_selected_is_array_child(): + return False + elem_a = tool.Ifc.get_entity(selected[0]) + elem_b = tool.Ifc.get_entity(selected[1]) + if elem_a is None or elem_b is None: + return False + rels = tool.Connection.find_rels(elem_a, elem_b) + return any(kind == "element-top" for _, kind in rels) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.disconnect_icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" + ) + self.disconnect_icon.hide = True + self.disconnect_op = self.disconnect_icon.target_set_operator("bim.disconnect_elements") + + def position_gizmos(self, context: bpy.types.Context) -> None: + self.disconnect_icon.hide = True + pair = _resolve_active_partner_pair(context) + if pair is None: + return + active, partner_obj, active_elem, partner_elem = pair + # Helper expects wall + slab regardless of which the user marked active. + if active_elem.is_a("IfcWall"): + wall_obj, slab_obj = active, partner_obj + elif partner_elem.is_a("IfcWall"): + wall_obj, slab_obj = partner_obj, active + else: + return + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + return + billboard_rot = gizmo.get_billboard_rotation(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + self.disconnect_icon.matrix_basis = gizmo.billboarded_at( + location + clearance, billboard_rot, scale=self.ICON_SCALE + ) + self.disconnect_icon.hide = False + self.disconnect_op.element_a_guid = active_elem.GlobalId + self.disconnect_op.element_b_guid = partner_elem.GlobalId + self.disconnect_icon.partner_obj = partner_obj class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index e44dabd916..abe4f45113 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -963,7 +963,11 @@ class EditObjectUI: @classmethod def draw_regen_operations(cls, row, ui_context): - if AuthoringData.data["is_regenable_element"]: + # ``AuthoringData.load`` flips ``is_loaded`` at entry as a recursion + # guard, so a partial load (any computation along the way raising) + # leaves the tail keys unset. ``.get()`` keeps the header draw alive + # until the underlying failure is investigated. + if AuthoringData.data.get("is_regenable_element"): row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context) @@ -1317,7 +1321,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.recalculate_profile() elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"): bpy.ops.bim.recalculate_fill() - elif self.active_class in ("IfcSpace"): + elif self.active_class in ("IfcSpace",): bpy.ops.bim.generate_space() def hotkey_S_M(self): diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 7243bd51b9..945cac5f66 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -30,7 +30,9 @@ classes = ( operator.BIM_FH_import_ifc, operator.BIM_OT_apply_pending_opening_cuts, operator.BIM_OT_dismiss_multi_instance_warning, + operator.BIM_OT_dismiss_pending_array_repair, operator.BIM_OT_dismiss_pending_opening_cuts, + operator.BIM_OT_select_pending_array_repair, operator.BIM_OT_select_pending_opening_cuts, operator.BIM_OT_load_clipping_planes, operator.BIM_OT_save_clipping_planes, @@ -86,6 +88,7 @@ classes = ( prop.FilterCategory, prop.Link, prop.EditedObj, + prop.PendingArrayRepair, prop.PendingOpeningRecut, prop.BIMProjectProperties, prop.MeasureToolSettings, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 8401d8c113..31b1248785 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1236,6 +1236,17 @@ class LoadProjectElements(bpy.types.Operator): f"Apply manually from the Project panel.", ) + props.pending_array_repair.clear() + if ifc_importer.broken_arrays: + for element in ifc_importer.broken_arrays: + item = props.pending_array_repair.add() + item.ifc_definition_id = element.id() + self.report( + {"WARNING"}, + f"{len(ifc_importer.broken_arrays)} array parent(s) reference missing child GUIDs. " + f"Inspect from the Project panel.", + ) + tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() @@ -3539,3 +3550,44 @@ class BIM_OT_select_pending_opening_cuts(bpy.types.Operator): tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects) self.report({"INFO"}, f"Selected {len(objects)} element(s).") return {"FINISHED"} + + +class BIM_OT_select_pending_array_repair(bpy.types.Operator): + bl_idname = "bim.select_pending_array_repair" + bl_label = "Select Array Parents With Missing Children" + bl_description = "Select the Blender objects of array parents whose BBIM_Array.Data references children that don't resolve in the file." + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: bpy.types.Context) -> set[str]: + ifc_file = tool.Ifc.get() + if ifc_file is None: + self.report({"INFO"}, "No IFC file loaded.") + return {"CANCELLED"} + objects: list[bpy.types.Object] = [] + for item in tool.Project.get_project_props().pending_array_repair: + try: + element = ifc_file.by_id(item.ifc_definition_id) + except RuntimeError: + continue + obj = tool.Ifc.get_object(element) + if obj is not None: + objects.append(obj) + if not objects: + self.report({"INFO"}, "No matching Blender objects found for the pending list.") + return {"CANCELLED"} + tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects) + self.report({"INFO"}, f"Selected {len(objects)} array parent(s).") + return {"FINISHED"} + + +class BIM_OT_dismiss_pending_array_repair(bpy.types.Operator): + bl_idname = "bim.dismiss_pending_array_repair" + bl_label = "Dismiss Pending Array Repair" + bl_description = ( + "Clear the pending array-repair list without acting on it. The underlying BBIM_Array.Data stays unchanged." + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: bpy.types.Context) -> set[str]: + tool.Project.get_project_props().pending_array_repair.clear() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index e1214d4824..53b239ee6c 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -306,6 +306,17 @@ class PendingOpeningRecut(PropertyGroup): ifc_definition_id: int +class PendingArrayRepair(PropertyGroup): + """One array parent whose ``BBIM_Array.Data`` references at least one + child GUID that does not resolve in the current IFC file. The user can + select these parents from the Project panel banner to inspect them.""" + + ifc_definition_id: IntProperty(name="IFC Definition ID") + + if TYPE_CHECKING: + ifc_definition_id: int + + class BIMProjectProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) is_loading: BoolProperty(name="Is Loading", default=False) @@ -372,6 +383,7 @@ class BIMProjectProperties(PropertyGroup): description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings", ) pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut) + pending_array_repair: CollectionProperty(name="Pending Array Repair", type=PendingArrayRepair) style_limit: IntProperty( name="Style Limit", default=300, @@ -538,6 +550,7 @@ class BIMProjectProperties(PropertyGroup): angular_tolerance: float void_limit: int pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut] + pending_array_repair: bpy.types.bpy_prop_collection_idprop[PendingArrayRepair] style_limit: int distance_limit: float false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"] diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 0759679d42..a793dbc455 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -205,6 +205,20 @@ class BIM_PT_project(Panel): row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY") row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL") + if pending := pprops.pending_array_repair: + box = self.layout.box() + box.alert = True + box.label(text="Arrays With Missing Children", icon="ERROR") + draw_multiline_text( + box.column(align=True), + f"{len(pending)} array parent(s) reference child GUIDs that don't exist in this file. " + f"The arrays loaded incomplete. Select to inspect, or dismiss.", + context=context, + ) + row = box.row(align=True) + row.operator("bim.select_pending_array_repair", text="Select Elements", icon="RESTRICT_SELECT_OFF") + row.operator("bim.dismiss_pending_array_repair", text="", icon="CANCEL") + if props.ifc_file: self.draw_loaded_project_ui(context) else: diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 84bff58927..ca1d176fda 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -517,6 +517,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): size=4, description="Color of not selected verts/edges (used in profile editing mode)", ) + clip_box_cap_color: bpy.props.FloatVectorProperty( + name="Clip Box Caps Color", + subtype="COLOR", + default=(0.0, 0.0, 0.0, 1.0), + min=0.0, + max=1.0, + size=4, + description="Fill color of clip-box cross-section caps", + ) decorator_color_special: bpy.props.FloatVectorProperty( name="Special Elements Color", subtype="COLOR", @@ -806,6 +815,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): layout.row().prop(self, "decorator_color_special") layout.row().prop(self, "decorator_color_error") layout.row().prop(self, "decorator_color_background") + bonsai.bim.helper.draw_expandable_panel( + layout, + context, + "Clip Box", + self.draw_clip_box_colors, + ) + + def draw_clip_box_colors(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + layout.row().prop(self, "clip_box_cap_color") def draw_default_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: box = layout.box() diff --git a/src/bonsai/bonsai/core/connection.py b/src/bonsai/bonsai/core/connection.py new file mode 100644 index 0000000000..71a2bb871c --- /dev/null +++ b/src/bonsai/bonsai/core/connection.py @@ -0,0 +1,99 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Shared post-disconnect cleanup dispatch. + +Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the +connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit +disconnect-on-delete). Each rel kind returned by +:py:meth:`bonsai.tool.connection.Connection.find_rels` / +:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new +rel kind means extending one dispatch table — both call sites benefit +automatically and the AST forward-compat guard enforces coverage. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bonsai.core.geometry +from bonsai.core.model import regenerate_wall_to_underside + +if TYPE_CHECKING: + import bpy + import ifcopenshell + + import bonsai.tool as tool + + +def disconnect_rel( + ifc: "type[tool.Ifc]", + geometry: "type[tool.Geometry]", + model: "type[tool.Model]", + connection: "type[tool.Connection]", + rel: "ifcopenshell.entity_instance", + kind: str, + elem: "ifcopenshell.entity_instance", + partner: "ifcopenshell.entity_instance", + skip_elem_recreate: bool = False, + skip_partner_recreate: bool = False, +) -> None: + """Run the post-disconnect cleanup for one rel. + + ``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate`` + flags suppress per-side regenerate / recreate work — used by the + cascade-on-delete to avoid re-extruding entities that are about to be + removed by ``remove_product``. For the disconnect operator (where neither + endpoint is being deleted), both flags stay False and the full cleanup + runs on both sides. + """ + if kind == "path": + bonsai.core.geometry.remove_connection(geometry, connection=rel) + if not skip_elem_recreate: + elem_obj = ifc.get_object(elem) + if elem_obj is not None: + model.recreate_wall(elem, elem_obj) + if not skip_partner_recreate: + partner_obj = ifc.get_object(partner) + if partner_obj is not None: + model.recreate_wall(partner, partner_obj) + elif kind == "element-top": + wall, _slab = connection.orient_element_top(rel, elem, partner) + ifc.run( + "geometry.disconnect_element", + relating_element=rel.RelatingElement, + related_element=rel.RelatedElement, + ) + # Skip the wall-side regenerate when the wall is itself being deleted — + # either it's the elem of this cascade pass, or it's the partner that + # was queued earlier in the same batch. + if (wall is elem and skip_elem_recreate) or (wall is partner and skip_partner_recreate): + return + wall_obj = ifc.get_object(wall) + if wall_obj is not None: + regenerate_wall_to_underside(ifc, geometry, model, [wall_obj]) + elif kind == "element": + ifc.run( + "geometry.disconnect_element", + relating_element=rel.RelatingElement, + related_element=rel.RelatedElement, + ) + else: + raise ValueError(f"Unknown rel kind: {kind!r}") diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 874675ea7f..30b1c9b515 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -167,12 +167,22 @@ def regenerate_wall_to_underside( model: type[tool.Model], wall_objs: list[bpy.types.Object], ) -> None: - """Re-clip walls to their connected underside objects after the slab has moved.""" + """Re-clip walls to their connected underside objects after the slab has moved. + + When a wall has no remaining slab connections — the case reached after the + last TOP rel is severed (via disconnect or via cascade-on-slab-delete) — the + stale trim booleans are cleaned up so the wall reverts to its pre-clip + extrusion instead of holding orphan ``IfcBooleanResult`` items and a dead + ``BBIM_Boolean`` pset. + """ clipped_objs = [] + reverted_objs = [] for obj in wall_objs: wall = ifc.get_entity(obj) slab_objs = model.get_connected_slab_objs(wall) if not slab_objs: + model.remove_wall_to_underside_booleans(wall) + reverted_objs.append(obj) continue if ifc.is_moved(obj): geometry.run_edit_object_placement(obj=obj) @@ -185,8 +195,9 @@ def regenerate_wall_to_underside( if clip: model.clip_wall_to_slab(wall, clip) clipped_objs.append(obj) - if clipped_objs: - model.reload_body_representation(clipped_objs) + refresh_objs = clipped_objs + reverted_objs + if refresh_objs: + model.reload_body_representation(refresh_objs) def extend_wall_to_slab( diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index f48165bd4c..91003174ad 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -195,6 +195,14 @@ class Collector: def assign(cls, obj, should_clean_users_collection=False): pass +@interface +class Connection: + def find_rel(cls, elem_a, elem_b): pass + def find_rels(cls, elem_a, elem_b): pass + def find_rels_for_element(cls, elem): pass + def orient_element_top(cls, rel, elem_a, elem_b): pass + + @interface class Context: def clear_context(cls): pass @@ -694,11 +702,13 @@ class Model: def load_openings(cls, openings): pass def purge_scene_openings(cls): pass def recalculate_walls(cls, objs): pass + def recreate_wall(cls, element, obj): pass def regenerate_array(cls, parent, data): pass def regenerate_profile(cls, obj): pass def regenerate_slab(cls, obj): pass def reload_body_representation(cls, obj_or_objects): pass def remove_wall_to_underside_booleans(cls, wall): pass + def strip_underside_booleans(cls, wall): pass def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 03716236e1..f927e5e1e1 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -30,7 +30,9 @@ from bonsai.tool.bsdd import Bsdd from bonsai.tool.cad import Cad from bonsai.tool.clash import Clash from bonsai.tool.classification import Classification +from bonsai.tool.clip_box import ClipBox from bonsai.tool.collector import Collector +from bonsai.tool.connection import Connection from bonsai.tool.context import Context from bonsai.tool.cost import Cost from bonsai.tool.covering import Covering diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 78b978849d..c468b0572e 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -30,7 +30,15 @@ import sys import tempfile import traceback import types -from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized +from collections.abc import ( + Callable, + Generator, + Iterable, + Iterator, + Mapping, + Sequence, + Sized, +) from datetime import datetime from functools import cache, lru_cache from pathlib import Path @@ -575,6 +583,120 @@ class Blender(bonsai.core.tool.Blender): else: decorator_cls.uninstall() + # Bonsai overrides Blender's default move/duplicate keymaps with macros + # that wrap TRANSFORM_OT_translate. While a macro is the outer modal + # entry, the inner TRANSFORM_OT_translate does not surface in + # window.modal_operators — the macro's own idname does. The ``BIM_OT_`` + # prefix is what Blender returns from ``bl_idname`` at runtime (the + # class declaration uses the dotted ``bim.`` form). + BONSAI_TRANSFORM_MACROS: frozenset[str] = frozenset( + { + "BIM_OT_override_move_macro", # G key + "BIM_OT_override_object_duplicate_move_macro", # Shift+D + "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D + "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D + } + ) + + @classmethod + def is_transform_modal_active(cls, context: bpy.types.Context) -> bool: + """True iff a Blender transform modal (G/R/S and siblings, including + Bonsai's macro overrides) is currently driving per-frame + ``matrix_world`` updates. Reads ``window.modal_operators`` — the + Blender 4.2+ collection of running modal operators. Callers gate + per-frame side effects (gizmo positioning, IFC persistence, etc.) + on this so they don't fire during the drag. + + Falls back to scanning every window in the window manager when + ``context.window`` is ``None`` — depsgraph callbacks run with a + limited context where ``context.window`` is typically missing, + but the modal is still active on one of the WM's windows. + """ + window = getattr(context, "window", None) + if window is not None and getattr(window, "modal_operators", None): + windows = [window] + else: + wm = getattr(context, "window_manager", None) or bpy.context.window_manager + if wm is None: + return False + windows = list(wm.windows) + for w in windows: + modal_ops = getattr(w, "modal_operators", None) + if not modal_ops: + continue + for op in modal_ops: + idname = op.bl_idname + if idname.startswith("TRANSFORM_OT_") or idname in cls.BONSAI_TRANSFORM_MACROS: + return True + return False + + @classmethod + def is_in_edit_mode(cls, context: Optional[bpy.types.Context] = None) -> bool: + """True iff the active object is in any edit-style mode. + + Catches every ``EDIT_*`` variant (mesh, curve, armature, + metaball, lattice, surface, text, grease pencil). Defaults to + ``OBJECT`` when the mode attribute is missing so background-mode + callers (no UI context) don't false-positive. + """ + ctx = context if context is not None else bpy.context + mode = getattr(ctx, "mode", "OBJECT") + return mode.startswith("EDIT_") + + @classmethod + def iter_view3d_regions(cls) -> Iterator[tuple[bpy.types.Area, bpy.types.Region, bpy.types.RegionView3D]]: + """Yield ``(area, region, region_3d)`` for every WINDOW region in every 3D viewport. + + Useful for features that need to act on every visible 3D viewport + (clip planes, draw handlers, region redraw fanout). Empty + generator when ``bpy.context.screen`` is unavailable (shutdown, + background mode without a screen). + """ + screen = getattr(getattr(bpy, "context", None), "screen", None) + if screen is None: + return + for area in screen.areas: + if area.type != "VIEW_3D": + continue + for region in area.regions: + if region.type != "WINDOW": + continue + region_3d = getattr(region, "data", None) + if region_3d is None: + continue + yield area, region, region_3d + + @classmethod + def get_or_create_collection(cls, scene: bpy.types.Scene, name: str) -> bpy.types.Collection: + """Return the named collection, creating + linking it to ``scene`` if absent.""" + collection = bpy.data.collections.get(name) + if collection is None: + collection = bpy.data.collections.new(name) + scene.collection.children.link(collection) + return collection + + @classmethod + def serialize_matrix(cls, matrix: Matrix) -> str: + """Serialize a 4x4 matrix as a 16-float comma-separated string. + + Round-trip pair with :meth:`deserialize_matrix`. Used for storing + a matrix in an IFC pset string property without losing precision + (``%.9g`` carries ~9 significant digits, enough for ``float32`` + round-trip). + """ + return ",".join(f"{matrix[r][c]:.9g}" for r in range(4) for c in range(4)) + + @classmethod + def deserialize_matrix(cls, text: str) -> Matrix: + """Inverse of :meth:`serialize_matrix`.""" + floats = [float(v) for v in text.split(",")] + return Matrix([tuple(floats[r * 4 : r * 4 + 4]) for r in range(4)]) + + @classmethod + def hash_matrix(cls, matrix: Matrix) -> int: + """Hash a 4x4 matrix by its 16 floats. Useful as a cache key.""" + return hash(tuple(matrix[r][c] for r in range(4) for c in range(4))) + @classmethod def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool: """True when the viewport camera is looking ~straight down (or up) the world Z axis. @@ -1274,7 +1396,10 @@ class Blender(bonsai.core.tool.Blender): @classmethod def get_object_from_guid(cls, guid: str) -> Union[bpy.types.Object, None]: - element = tool.Ifc.get().by_guid(guid) + try: + element = tool.Ifc.get().by_guid(guid) + except RuntimeError: + return None obj = tool.Ifc.get_object(element) if obj: return obj diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 957c8339fb..2a1f5f9c69 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -206,6 +206,237 @@ class Cad: """ return geometry.intersect_line_plane(v1, v2, plane_co, plane_no) + @classmethod + def obb_world_clip_planes( + cls, + center: Vector, + axes: tuple[Vector, Vector, Vector], + half_extents: Vector, + ) -> tuple[tuple[float, float, float, float], ...]: + """Return the 6 inward world clip planes of an oriented bounding box. + + Each plane is a 4-tuple ``(a, b, c, d)`` for the equation + ``a*x + b*y + c*z + d``; a point is KEPT when the value is ``>= 0`` + for every plane, matching ``RegionView3D.clip_planes`` semantics. + Return order is ``(+x, -x, +y, -y, +z, -z)`` where ``+x`` is the face + on the positive side of ``axes[0]``. ``axes`` are assumed orthonormal. + """ + cx, cy, cz = center.x, center.y, center.z + planes: list[tuple[float, float, float, float]] = [] + for i in range(3): + ux, uy, uz = axes[i].x, axes[i].y, axes[i].z + h = float(half_extents[i]) + px, py, pz = cx + h * ux, cy + h * uy, cz + h * uz + nx, ny, nz = -ux, -uy, -uz + planes.append((nx, ny, nz, -(nx * px + ny * py + nz * pz))) + px, py, pz = cx - h * ux, cy - h * uy, cz - h * uz + planes.append((ux, uy, uz, -(ux * px + uy * py + uz * pz))) + return tuple(planes) + + @classmethod + def obb_clip_planes_from_matrix( + cls, + matrix_world: Matrix, + expand: float = 0.0, + expand_rel: float = 0.0, + ) -> tuple[tuple[float, float, float, float], ...]: + """Return the 6 inward world clip planes for the unit cube under ``matrix_world``. + + The implicit box is ``[-1, +1]^3`` in object-local space, so the + host's ``matrix_world`` translation is the world centre, its + rotation orients the box axes, and each column's magnitude is the + world half-extent along that local axis. ``expand`` (absolute + world units) and ``expand_rel`` (fraction of each axis's + half-extent) both add an outward margin — callers that visualise + the box with overlapping geometry (e.g. an empty CUBE display + sharing edges with the clip planes) pass non-zero values so the + box's own wireframe sits safely INSIDE the clip volume. Use the + relative form when the box is rendered at varying scales, since + the depth-buffer precision needed to keep an edge unclipped grows + with world-coordinate magnitude. + """ + world_center = matrix_world.col[3].xyz + linear = matrix_world.to_3x3() + world_axes = [] + world_half_list = [] + for i in range(3): + v = linear.col[i].copy() + length = v.length + if length > 0.0: + world_axes.append(v / length) + else: + world_axes.append(Vector((0.0, 0.0, 0.0))) + world_half_list.append(length + expand + length * expand_rel) + return cls.obb_world_clip_planes( + world_center, + (world_axes[0], world_axes[1], world_axes[2]), + Vector(world_half_list), + ) + + @classmethod + def point_is_inside_clip_planes( + cls, + planes: tuple[tuple[float, float, float, float], ...], + point: Vector, + eps: float = 1e-6, + ) -> bool: + """True iff ``point`` is on the kept side of every plane (inclusive).""" + x, y, z = point.x, point.y, point.z + for a, b, c, d in planes: + if a * x + b * y + c * z + d < -eps: + return False + return True + + @classmethod + def newell_normal(cls, points: Sequence) -> Vector: + """Newell's-method normal for a (possibly non-planar) 3D polygon ring. + + Robust for thin / near-degenerate rings where a two-edge cross + product would be unstable. + """ + nx = ny = nz = 0.0 + n = len(points) + for i in range(n): + cur = points[i] + nxt = points[(i + 1) % n] + nx += (cur[1] - nxt[1]) * (cur[2] + nxt[2]) + ny += (cur[2] - nxt[2]) * (cur[0] + nxt[0]) + nz += (cur[0] - nxt[0]) * (cur[1] + nxt[1]) + return Vector((nx, ny, nz)) + + @classmethod + def plane_basis(cls, points: Sequence) -> tuple[Vector, Vector]: + """Return an orthonormal ``(u, v)`` basis for the ring's best-fit plane.""" + normal = cls.newell_normal(points) + if normal.length < 1e-12: + normal = Vector((0.0, 0.0, 1.0)) + normal = normal.normalized() + ref = Vector((1.0, 0.0, 0.0)) + if abs(normal.x) > 0.9: + ref = Vector((0.0, 1.0, 0.0)) + u = normal.cross(ref) + if u.length < 1e-12: + ref = Vector((0.0, 0.0, 1.0)) + u = normal.cross(ref) + u = u.normalized() + v = normal.cross(u).normalized() + return u, v + + @classmethod + def tessellate_ring_planar(cls, polyline_list: list[list]) -> list[tuple[int, int, int]]: + """Triangulate ``[outer, *inners]`` 3D coord rings in their own plane. + + Projects every ring onto the outer ring's best-fit plane and + returns ``(i, j, k)`` index triples into the flat + ``outer + inners[0] + inners[1] + ...`` vertex list. Falls + back to a shapely constrained Delaunay triangulation when + ``mathutils.geometry.tessellate_polygon`` silently leaves ring + vertices unused (its known failure mode on complex concave + polygons-with-holes). + """ + from mathutils.geometry import tessellate_polygon + + if not polyline_list or not polyline_list[0]: + return [] + outer = polyline_list[0] + u, v = cls.plane_basis(outer) + origin = Vector(outer[0]) + + def _project_xy(ring): + return [((Vector(co) - origin).dot(u), (Vector(co) - origin).dot(v)) for co in ring] + + projected_xy = [_project_xy(ring) for ring in polyline_list] + projected = [[Vector((x, y, 0.0)) for x, y in ring] for ring in projected_xy] + triangles = tessellate_polygon(projected) + + n_total = sum(len(r) for r in projected_xy) + used = {i for tri in triangles for i in tri} + if triangles and len(used) >= n_total: + return triangles + + fallback = cls._tessellate_via_shapely(projected_xy) + return fallback if fallback else triangles + + @classmethod + def _tessellate_via_shapely(cls, projected_xy: list[list[tuple[float, float]]]) -> list[tuple[int, int, int]]: + """Constrained-Delaunay fallback for :meth:`tessellate_ring_planar`. + + Honours the polygon's boundary AND holes. Returns ``[]`` when + shapely is unavailable or the polygon can't be cleaned via + ``buffer(0)``. + """ + try: + from shapely.geometry import Polygon + except Exception: + return [] + outer = projected_xy[0] + inners = projected_xy[1:] + if len(outer) < 3: + return [] + try: + poly = Polygon(outer, inners) + poly = poly if poly.is_valid else poly.buffer(0) + if poly.is_empty: + return [] + except Exception: + return [] + + flat = list(outer) + for r in inners: + flat.extend(r) + + def _key(x, y): + return (round(x, 6), round(y, 6)) + + index_of: dict[tuple[float, float], int] = {} + for idx, (x, y) in enumerate(flat): + index_of.setdefault(_key(x, y), idx) + + try: + from shapely import constrained_delaunay_triangles + + res = constrained_delaunay_triangles(poly) + tri_geoms = list(getattr(res, "geoms", []) or []) + except Exception: + try: + from shapely.ops import triangulate + + tri_geoms = [t for t in triangulate(poly) if poly.contains(t.representative_point())] + except Exception: + return [] + + out: list[tuple[int, int, int]] = [] + for t in tri_geoms: + coords = list(t.exterior.coords)[:-1] + if len(coords) != 3: + continue + idxs = [index_of.get(_key(x, y)) for x, y in coords] + if any(i is None for i in idxs): + continue + out.append(tuple(idxs)) + return out + + @classmethod + def corners_might_cross_clip_planes( + cls, + planes: tuple[tuple[float, float, float, float], ...], + corners: Sequence[Vector], + ) -> bool: + """Conservative reject test: True if ``corners`` might cross the clip volume. + + Returns False only when at least one plane has ALL corners on its + rejected side — meaning the convex hull of ``corners`` is fully + outside the clip volume and a per-mesh bisect can be skipped. + Returns True otherwise (possibly with false positives — never + false negatives), so callers always cap any object that actually + crosses the box. ``corners`` is typically the 8 world-space corners + of an object's bound box. + """ + for a, b, c, d in planes: + if all(a * v.x + b * v.y + c * v.z + d < 0.0 for v in corners): + return False + return True + def intersect_edge_plane_v2(v1, v2, plane_co, plane_no, eps=1e-9): """ Numpy version of intersect_edge_plane diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py new file mode 100644 index 0000000000..1b30328a1d --- /dev/null +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -0,0 +1,991 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +from __future__ import annotations + +import contextlib +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any, Optional + +import bpy + +import bonsai.tool as tool + +if TYPE_CHECKING: + from bonsai.bim.module.clip_box.prop import ( + BIMClipBoxProperties, + BIMSceneClipBoxProperties, + ) + + +PlaneTuple = tuple[float, float, float, float] +PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple] + +# Outward margin (world units) so the empty's CUBE display edges sit +# safely INSIDE the clip volume. Absolute (not relative-to-scale) +# because a relative multiplier balloons with scale and produces a +# visibly-wrong gap between the wireframe and the clipped geometry. +# Sub-mesh-precision value: visually invisible at any reasonable IFC +# scale yet large enough to keep the empty's own wireframe edges off +# the clip planes when float-precision accumulation pushes a corner +# a fractional epsilon outward. +_CLIP_EXPAND_ABS = 1e-6 + + +class ClipBox: + """Driver for the viewport clip-box feature. + + Owns the bridge between ``BIMClipBoxProperties`` on a host empty and + Blender's ``RegionView3D.clip_planes`` machinery. Plane math is in + ``Cad``; this class is the bpy adapter. + + Region-ownership: ``_owned`` tracks which regions we have armed so + a subsequent arm on the same region can skip the first-arm operator + path and write planes directly. Keyed by ``region.as_pointer()``. + """ + + _owned: set[int] = set() + _region_by_key: dict[int, tuple[Any, Any]] = {} + _refresh_pending: bool = False + _last_seen_ifc_id: int = 0 + # Tracks the last matrix we persisted to the pset, keyed by Blender + # object name. Lets the depsgraph handler detect committed transform + # changes on clip boxes rehydrated from the project pset on file load + # (which have no modal poller watching them). + _persisted_matrices: dict[str, tuple] = {} + # Names of clip boxes whose matrix changed during a transform modal. + # Flushed when the gate flips back to inactive — one save per dirty + # box on commit, no writes during the drag. + _dirty_for_save: set[str] = set() + # Per-object cache of cross-section cap triangles in world space. + # Key: obj.name. Value: (cache_key_tuple, gpu_batch). Invalidated when + # the object's mesh data block, world matrix, or the clip box matrix + # changes. Rebuild is skipped while any transform modal is dragging + # matrix_world so a continuous G/R/S shows stale caps and rebuilds on + # commit instead of re-bisecting every mesh per frame. + _cap_cache: dict[str, tuple[tuple, Any]] = {} + _last_cap_clip_box_hash: int = 0 + # Debounce window for cap rebuild from external (unknown-modal) drags: + # each depsgraph tick reschedules a timer this far in the future, so + # a burst of N ticks collapses to one rebuild after the storm. + _CAP_REBUILD_DEBOUNCE_SECONDS: float = 1.0 + _last_modal_state: bool = False + _pending_cap_rebuild: Optional[Callable[[], None]] = None + # Per-object matrix hash baseline used to tell a real transform + # change from Blender's "selection touched the flag" noise: when a + # depsgraph tick reports is_updated_transform on an Object, the + # relevance filter compares the live hash against this baseline. + _last_seen_object_matrices: dict[str, int] = {} + + @classmethod + def get_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> BIMSceneClipBoxProperties: + if scene is None: + scene = bpy.context.scene + return scene.BIMSceneClipBoxProperties + + @classmethod + def get_object_props(cls, obj: bpy.types.Object) -> BIMClipBoxProperties: + return obj.BIMClipBoxProperties + + @classmethod + def select_active_clip_box(cls, context: bpy.types.Context) -> None: + """Deselect everything, then select + activate the active clip box's empty. + + Wired into the panel UIList's ``active_clip_box_index`` update + so clicking a row in the list does the standard outliner-style + focus: the user can immediately G/R/S the box they just picked. + + Short-circuits when the active object is already the target — + keeps multi-selections intact when the index changed because the + depsgraph sync detected the user clicking the empty directly. + No-op when no active box is resolvable. + """ + obj = cls.get_active_clip_box(context.scene) + if obj is None: + return + if getattr(context, "active_object", None) is obj: + return + tool.Blender.set_objects_selection( + context, active_object=obj, selected_objects=[obj], clear_previous_selection=True + ) + + @classmethod + def get_active_clip_box(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[bpy.types.Object]: + """Return the host empty of the currently active clip box, or ``None``.""" + props = cls.get_scene_props(scene) + index = props.active_clip_box_index + if index < 0 or index >= len(props.clip_boxes): + return None + obj = props.clip_boxes[index].obj + if obj is None: + return None + obj_props = cls.get_object_props(obj) + if not obj_props.is_clip_box: + return None + return obj + + @classmethod + def compute_planes(cls, obj: bpy.types.Object) -> PlaneSet: + """Build the 6 inward world clip planes from the host empty's matrix_world. + + The empty's CUBE display spans local ``[-1, +1]^3`` (with + ``empty_display_size = 1``); ``matrix_world`` carries translation, + rotation, and per-axis scale, so the clip planes track the cube + exactly as it looks in the viewport. A tiny outward margin + prevents the cube's own wireframe from being clipped by its own + planes. + """ + return tool.Cad.obb_clip_planes_from_matrix(obj.matrix_world, expand=_CLIP_EXPAND_ABS) + + @classmethod + def compute_planes_from_matrix(cls, matrix: Any) -> PlaneSet: + """Same as :meth:`compute_planes` but accepts a raw matrix. + + Used by the depsgraph handler to read the *evaluated* matrix during + a live G/R/S transform — that matrix reflects the in-progress + transform offset, while ``obj.matrix_world`` stays at the + pre-transform value until the operator commits on release. + """ + return tool.Cad.obb_clip_planes_from_matrix(matrix, expand=_CLIP_EXPAND_ABS) + + @classmethod + def apply_clip_planes(cls, planes: PlaneSet) -> None: + """Drive every open 3D viewport's clip planes to ``planes``. + + Always calls ``view3d.clip_border`` to refresh the region's + ``clip_bb`` at the CURRENT view. Edit-mode click-select tests + against ``clip_local`` derived from that bbox; if we don't keep + ``clip_bb`` fresh, the user can orbit the view (or transform + the clip box) and find click-select rejecting verts that ARE + visible because the test is using a stale view-frustum bbox + captured the last time we armed. Re-arming on every commit + keeps the bbox aligned with the view the user is actually at. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + key = region.as_pointer() + cls._owned.add(key) + cls._region_by_key[key] = (area, region) + cls._arm_region(area, region, region_3d, planes) + + @classmethod + def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None: + """Initialize the region's clip machinery and write ``planes``. + + ``view3d.clip_border`` with a FULL-REGION rect arms ``RV3D_CLIPPING`` + without leaving the C-side ``clipbb`` degenerate (which would break + edit-mode click-select). Caller must guarantee a context in which + operators are legal (not a draw handler / depsgraph callback). + """ + with bpy.context.temp_override(area=area, region=region): + bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height) + region_3d.clip_planes = planes + region_3d.use_clip_planes = True + region_3d.update() + + @classmethod + def clear_clip_planes(cls) -> None: + """Disable clip planes on every 3D viewport region. + + Unchecking ``enabled`` or removing a clip box turns clipping + off; any prior Alt+B clip is NOT restored. The ``_owned`` + ownership table is preserved across this clear so a later + re-enable can skip the ``view3d.clip_border`` re-init (which + would re-derive ``clip_bb`` at the current view and break + edit-mode click-select alignment). The full ownership reset + happens only on IFC reload or addon unregister. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + with contextlib.suppress(ReferenceError, AttributeError, TypeError): + region_3d.use_clip_planes = False + region.tag_redraw() + + @classmethod + def _active_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[BIMSceneClipBoxProperties]: + """Scene PG iff the clipping pipeline should drive this tick, else ``None``. + + Most sessions run with clipping disabled, so the cheap + ``enabled`` check fires before any active-box lookup or + per-mesh work. Callers compose with their own further checks + (e.g. ``show_caps`` for the cap pipeline) on the returned PG. + """ + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + if not scene_props.enabled: + return None + return scene_props + + @classmethod + def refresh(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Re-arm or clear the viewport clip based on the active clip box state.""" + if cls._active_scene_props(scene) is None: + cls.clear_clip_planes() + return + obj = cls.get_active_clip_box(scene) + if obj is None: + cls.clear_clip_planes() + return + cls.apply_clip_planes(cls.compute_planes(obj)) + + @classmethod + def schedule_refresh(cls) -> None: + """Schedule a refresh on the next idle tick. + + PropertyGroup ``update=`` callbacks must not call ``bpy.ops`` (which + ``apply_clip_planes`` may need for the first-time arm) — doing so + from within a property write disrupts gizmo modal accounting and + can leave the operator stack inconsistent. Deferring via a 0-delay + timer hands the refresh to Blender's main loop, where operators are + legal. Debounced: a flag suppresses repeats while one is pending. + """ + if cls._refresh_pending: + return + cls._refresh_pending = True + + def _do_refresh(): + cls._refresh_pending = False + cls.refresh() + return None + + bpy.app.timers.register(_do_refresh, first_interval=0.0) + + @classmethod + def reset_ownership(cls) -> None: + """Drop the ownership table without touching any region. Used on register/reload.""" + cls._owned.clear() + cls._region_by_key.clear() + + PSET_NAME = "BBIM_ClipBoxes" + COLLECTION_NAME = "BBIM_ClipBoxes" + + @classmethod + def _get_project_pset_entity(cls, create: bool = False): + """Return the ``IfcPropertySet`` entity holding the clip-box state. + + Stored on ``IfcProject`` because IFC's IfcRoot pipeline locks and + strips object scale on export, which a clip box (whose size IS + its scale) cannot tolerate. A project-level pset side-steps any + per-entity placement sync. + """ + import ifcopenshell.util.element + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None + projects = ifc_file.by_type("IfcProject") + if not projects: + return None + project = projects[0] + existing = ifcopenshell.util.element.get_psets(project).get(cls.PSET_NAME) + if existing is not None: + return ifc_file.by_id(existing["id"]) + if not create: + return None + return tool.Ifc.run("pset.add_pset", product=project, name=cls.PSET_NAME) + + @classmethod + def mark_dirty_for_save(cls, obj_name: str) -> None: + """Note that ``obj_name`` has an unpersisted matrix change. + + Accumulates dirty names during a transform drag without + touching the IFC graph; the flush gate writes exactly one + save per dirty box once no transform modal is active. + """ + cls._dirty_for_save.add(obj_name) + + @classmethod + def flush_pending_saves(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Write the pset iff there's pending dirt AND no transform modal. + + Called from the depsgraph handler every tick. Reading + ``tool.Blender.is_transform_modal_active(bpy.context)`` checks + ``window.modal_operators`` against the known transform op names + (Blender vanilla + Bonsai macro overrides — kept centrally in + :attr:`tool.Blender.BONSAI_TRANSFORM_MACROS`), so this gate + survives any Bonsai keymap override and any Python script that + wraps the same operators. + """ + if not cls._dirty_for_save: + return + if tool.Ifc.get() is None: + cls._dirty_for_save.clear() + return + if tool.Blender.is_transform_modal_active(bpy.context): + return + cls._dirty_for_save.clear() + cls.save_to_project_pset(scene) + + @classmethod + def save_to_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Snapshot the active clip-box state to ``IfcProject.BBIM_ClipBoxes``. + + Each clip box contributes ``Box__Name`` and ``Box__Matrix`` + (a 16-float comma-separated string). ``Count`` is the canonical + size. ``enabled`` is intentionally not persisted — opening a file + should never silently hide geometry behind a remembered toggle. + No-op when no IFC file is loaded. + """ + if tool.Ifc.get() is None: + return + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + + pset = cls._get_project_pset_entity(create=True) + if pset is None: + return + + properties: dict[str, str | int] = { + "Count": len(scene_props.clip_boxes), + "ShowCaps": int(scene_props.show_caps), + } + for i, entry in enumerate(scene_props.clip_boxes): + obj = entry.obj + if obj is None: + continue + properties[f"Box_{i}_Name"] = obj.name + properties[f"Box_{i}_Matrix"] = tool.Blender.serialize_matrix(obj.matrix_world) + tool.Ifc.run("pset.edit_pset", pset=pset, properties=properties) + + @classmethod + def load_from_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Rehydrate clip boxes from ``IfcProject.BBIM_ClipBoxes``. + + Idempotent: drops stale list entries (deleted hosts / un-flagged + objects), then for each saved box, creates the empty if absent + or updates its matrix if the .blend reload already restored it. + + ``scene_props.enabled`` is NOT touched: it defaults to ``False`` + (so a fresh IFC load over a fresh .blend never silently hides + geometry), and Blender's normal .blend persistence carries the + user's saved toggle through .blend reload. + """ + import ifcopenshell.util.element + + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + + for index in range(len(scene_props.clip_boxes) - 1, -1, -1): + entry = scene_props.clip_boxes[index] + obj = entry.obj + if obj is None or not cls.get_object_props(obj).is_clip_box: + scene_props.clip_boxes.remove(index) + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + projects = ifc_file.by_type("IfcProject") + if not projects: + return + pset = ifcopenshell.util.element.get_psets(projects[0]).get(cls.PSET_NAME) + if not pset: + return + + show_caps_raw = pset.get("ShowCaps") + if show_caps_raw is not None: + scene_props.show_caps = bool(int(show_caps_raw)) + # enabled is intentionally NOT read from the pset — see docstring. + + existing_by_name = {entry.obj.name: entry.obj for entry in scene_props.clip_boxes if entry.obj} + existing_objs = set(existing_by_name.values()) + count = int(pset.get("Count", 0) or 0) + for i in range(count): + name = pset.get(f"Box_{i}_Name") or f"ClipBox.{i:03d}" + matrix_str = pset.get(f"Box_{i}_Matrix") + if not matrix_str: + continue + matrix = tool.Blender.deserialize_matrix(matrix_str) + existing_obj = bpy.data.objects.get(name) + if existing_obj is None: + # Fresh IFC load: no .blend backing, no viewport clip state. + # Create the empty, place it, and force enabled=False so we + # don't silently hide geometry behind a box the user forgot. + obj = bpy.data.objects.new(name, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.show_in_front = True + collection = tool.Blender.get_or_create_collection(scene, cls.COLLECTION_NAME) + collection.objects.link(obj) + obj.matrix_world = matrix + cls.get_object_props(obj).is_clip_box = True + else: + # .blend reload: the empty (and the scene-level enabled + # toggle) survived Blender's own session save. Update the + # matrix in case the pset diverged from the .blend snapshot. + obj = existing_obj + obj.matrix_world = matrix + cls.get_object_props(obj).is_clip_box = True + if obj not in existing_objs: + entry = scene_props.clip_boxes.add() + entry.obj = obj + existing_objs.add(obj) + + if scene_props.clip_boxes and scene_props.active_clip_box_index >= len(scene_props.clip_boxes): + scene_props.active_clip_box_index = 0 + + @classmethod + def apply_clip_planes_direct(cls, planes: PlaneSet) -> None: + """Direct-write variant for contexts where ``bpy.ops`` is illegal. + + Skips the first-arm path (which needs ``view3d.clip_border``) + and writes planes directly to every armed region. + ``region_3d.update()`` pushes the new clip planes to the GPU + buffer the rasteriser samples — without it the planes sit in + the data block and the next frame still uses the previous GPU + state. ``tag_redraw`` requests that the region actually + redraws this frame. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + if not region_3d.use_clip_planes: + continue + key = region.as_pointer() + cls._region_by_key[key] = (area, region) + region_3d.clip_planes = planes + region_3d.update() + region.tag_redraw() + + @classmethod + def _sync_collection_to_list(cls, scene: bpy.types.Scene) -> None: + """Add any clip-box-flagged empties not yet in ``scene_props.clip_boxes``. + + Bonsai's duplicate-move macros (Shift+D, Alt+D, Ctrl+Shift+D) + deep-copy the source's ``BIMClipBoxProperties``, so the + duplicated empty carries ``is_clip_box=True`` but no scene-list + entry exists for it. This sync turns the duplicate into a + first-class clip box matching the UIList duplicate button: a + new entry, set active, persisted to the pset. + + Scoped to the ``BBIM_ClipBoxes`` collection so the cost is O(N) + in the number of clip boxes, not O(N) in the whole scene. + """ + scene_props = cls.get_scene_props(scene) + known_objs = {entry.obj for entry in scene_props.clip_boxes if entry.obj} + collection = bpy.data.collections.get(cls.COLLECTION_NAME) + if collection is None: + return + appended = False + for obj in collection.objects: + if obj in known_objs: + continue + if obj.type != "EMPTY": + continue + obj_props = cls.get_object_props(obj) + if not obj_props.is_clip_box: + continue + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + appended = True + if appended and tool.Ifc.get() is not None: + cls.save_to_project_pset(scene) + + @classmethod + def on_depsgraph_update(cls, scene, depsgraph) -> None: + """Safety-net re-arm, IFC-load rehydrate, sync + pset persistence. + + - **Shutdown guard**: skips when ``bpy.context.screen`` is ``None`` + so the persistent handler can't fault against freed UI memory. + - **IFC reload detection**: when ``id(tool.Ifc.get())`` changes, + drop the now-stale ``_owned`` table (the regions from the old + screen were freed) and rehydrate clip boxes from the new + project's ``BBIM_ClipBoxes`` pset. + - **Collection-to-list sync**: catches clip-box empties created + outside ``bim.add_clip_box`` / ``bim.duplicate_clip_box`` — + notably Bonsai's Shift+D / Alt+D / Ctrl+Shift+D macros, which + deep-copy the source's ``BIMClipBoxProperties`` (including + ``is_clip_box=True``) but don't register the copy with us. + Detection lives here so any future entry path is handled too. + - **Live preview safety net**: re-applies the clip planes from + the active box's evaluated matrix. ``on_pre_view`` is the + primary live-preview path; this is what catches matrix changes + outside any modal (Python set, undo, constraint update). + - **Pset persistence**: when ``obj.matrix_world`` differs from + the last persisted snapshot, write it to the project pset. + Blender's G/R/S modal only commits ``matrix_world`` on release, + so this branch fires once per commit — exactly the cadence the + user expects for "save my latest transform". + """ + if getattr(bpy.context, "screen", None) is None: + return + if cls._active_scene_props(scene) is None: + return + ifc_file = tool.Ifc.get() + ifc_id = id(ifc_file) if ifc_file is not None else 0 + if ifc_id != cls._last_seen_ifc_id: + cls._last_seen_ifc_id = ifc_id + cls._owned.clear() + cls._region_by_key.clear() + cls._persisted_matrices.clear() + cls._last_seen_object_matrices.clear() + if ifc_file is not None: + cls.load_from_project_pset(scene) + # Orphan-empty adoption is deferred while a transform modal is + # dragging so the active-index change on adoption can't disrupt + # the move. + if not tool.Blender.is_transform_modal_active(bpy.context): + cls._sync_collection_to_list(scene) + obj = cls.get_active_clip_box(scene) + if obj is None: + return + + current_matrix = tuple(tuple(row) for row in obj.matrix_world) + prev_matrix = cls._persisted_matrices.get(obj.name) + if prev_matrix != current_matrix: + cls._persisted_matrices[obj.name] = current_matrix + # Only persist when an IFC file is loaded; otherwise the box + # is purely Blender-side and there's nothing to write to. + # Mark dirty here, FLUSH below — the gate suppresses writes + # while a transform modal is dragging so one drag produces + # one save on release, not N saves per frame. + if ifc_file is not None and prev_matrix is not None: + cls.mark_dirty_for_save(obj.name) + cls.flush_pending_saves(scene) + + try: + eval_obj = obj.evaluated_get(depsgraph) + matrix = eval_obj.matrix_world + except (AttributeError, RuntimeError, ReferenceError): + return + cls.apply_clip_planes_direct(cls.compute_planes_from_matrix(matrix)) + + @classmethod + def on_pre_view(cls) -> None: + """Per-redraw live preview hook. + + Installed as a ``SpaceView3D.draw_handler_add`` at ``PRE_VIEW``. + Reads the active clip box's evaluated matrix and writes the + clip planes to ``bpy.context.region_data`` — the region being + rendered THIS frame, so no ``temp_override`` is needed. + + IFC pset writes are NOT performed here; that's the depsgraph + handler's job (it fires on transform commit and writes through + the operator transaction path). + """ + if cls._active_scene_props() is None: + return + obj = cls.get_active_clip_box() + if obj is None: + return + region_3d = getattr(bpy.context, "region_data", None) + if region_3d is None or not region_3d.use_clip_planes: + return + try: + depsgraph = bpy.context.evaluated_depsgraph_get() + matrix = obj.evaluated_get(depsgraph).matrix_world + except (AttributeError, RuntimeError, ReferenceError): + return + region_3d.clip_planes = cls.compute_planes_from_matrix(matrix) + region_3d.update() + + # ------------------------------------------------------------------ + # Cross-section caps + # + # When the clip box is enabled, each IfcProduct mesh that crosses + # the box gets a "cap" polygon drawn where its geometry intersects + # a clip plane — so cut surfaces appear filled instead of hollow. + # The pipeline (``bmesh.ops.bisect_plane(clear_outer=True)`` per + # plane, then ``bmesh.ops.contextual_create`` to fill cut edges) + # runs on a temp BMesh per object so the source mesh is untouched. + # ------------------------------------------------------------------ + + @classmethod + def _compute_caps_for_object( + cls, + obj: bpy.types.Object, + world_planes: PlaneSet, + depsgraph: Optional[Any] = None, + ) -> list[tuple[float, float, float]]: + """Return triangle vertices for ``obj``'s cap polygons. + + Flat list of ``(x, y, z)`` tuples in world space, ready for a + ``batch_for_shader("TRIS", ...)`` upload. Empty when the + object's bound box doesn't cross any clip plane. + + Uses the evaluated mesh (modifier stack applied) when a + ``depsgraph`` is passed, so caps match the rendered geometry of + objects with subsurf / boolean / mirror modifiers. Falls back to + ``obj.data`` only for callers without a depsgraph (e.g. unit + tests that fabricate a mesh outside any eval context). + """ + import bmesh + from mathutils import Vector + + bm = bmesh.new() + eval_obj = None + try: + if depsgraph is not None: + try: + eval_obj = obj.evaluated_get(depsgraph) + mesh = eval_obj.to_mesh() + bm.from_mesh(mesh) + except (RuntimeError, ReferenceError): + return [] + else: + try: + bm.from_mesh(obj.data) + except (RuntimeError, ReferenceError): + return [] + + ws_to_ls = obj.matrix_world.inverted_safe() + rot = ws_to_ls.to_quaternion() + planes_local = [] + for plane in world_planes: + inward_world = Vector(plane[:3]) + d = plane[3] + point_on_plane_world = inward_world * -d + plane_co_local = ws_to_ls @ point_on_plane_world + # bisect_plane removes the +plane_no side when clear_outer=True; + # our inward normal points INTO the box, so we negate to clear + # the box's outside. + plane_no_local = (rot @ -inward_world).normalized() + planes_local.append((plane_co_local, plane_no_local)) + + cap_layer = tool.Geometry.bisect_and_cap(bm, planes_local) + if cap_layer is None: + return [] + + cap_faces = [f for f in bm.faces if f.is_valid and f[cap_layer]] + if not cap_faces: + return [] + + mw = obj.matrix_world + return cls._triangulate_cap_faces(cap_faces, mw) + finally: + bm.free() + if eval_obj is not None: + with contextlib.suppress(RuntimeError, ReferenceError, AttributeError): + eval_obj.to_mesh_clear() + + @classmethod + def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]: + """Yield mesh objects eligible for capping: visible ``IfcElement``s. + + Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so + spatial structure (``IfcSpace``, ``IfcBuildingStorey``, + ``IfcSite``) and annotations / grids never get capped — they're + non-physical containers / overlays that shouldn't sprout solid + fill polygons at clip boundaries. + """ + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + for obj in scene.objects: + if obj.type != "MESH" or obj.data is None: + continue + if not obj.visible_get(): + continue + entity = tool.Ifc.get_entity(obj) + if entity is None or not entity.is_a("IfcElement"): + continue + yield obj + + @classmethod + def rebuild_cap_cache( + cls, + scene: Optional[bpy.types.Scene] = None, + depsgraph: Optional[Any] = None, + ) -> None: + """Recompute the per-object cap-vertex cache from the active clip box. + + No-op while a transform modal is dragging ``matrix_world`` — the + existing cache stays in place and the user sees stale caps until + the drag commits. Per-object cache entries are reused when the + object's mesh, world matrix, and the clip-box matrix all match + the prior key. Stale entries (deleted objects, disabled box, + unloaded IFC) are pruned. + + When ``depsgraph`` is supplied (the typical handler path), per-mesh + caps are computed from the evaluated mesh so modifier stacks are + honoured; without it, raw source meshes are used. + """ + scene_props = cls._active_scene_props(scene) + if scene_props is None or not scene_props.show_caps: + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + return + if scene is None: + scene = bpy.context.scene + active = cls.get_active_clip_box(scene) + if active is None: + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + return + if tool.Blender.is_transform_modal_active(bpy.context): + return + + # Cap with the SAME expanded planes the viewport clips against + # (cls.compute_planes applies the _CLIP_EXPAND_ABS margin), so the + # cap face lines up with the visible cut. Using the un-expanded + # planes would leave a visible margin-sized gap between the cut + # mesh edge and the cap. + world_planes = cls.compute_planes(active) + clip_box_hash = hash(world_planes) + cls._last_cap_clip_box_hash = clip_box_hash + + from mathutils import Vector + + live_names: set[str] = set() + for obj in cls._iter_capable_objects(scene): + live_names.add(obj.name) + mesh = obj.data + cache_key = ( + getattr(mesh, "session_uid", id(mesh)), + tool.Blender.hash_matrix(obj.matrix_world), + clip_box_hash, + ) + cached = cls._cap_cache.get(obj.name) + if cached is not None and cached[0] == cache_key: + continue + # Cheap AABB-vs-clip-box rejection before the expensive bisect. + # bound_box has 8 corners in object-local space — transform to + # world and check whether they're all on the outside of any + # clip plane. If so, the mesh can't produce a cap from this + # box and we skip the per-mesh bisect. + mw = obj.matrix_world + world_corners = [mw @ Vector(c) for c in obj.bound_box] + if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners): + cls._cap_cache[obj.name] = (cache_key, None) + continue + verts = cls._compute_caps_for_object(obj, world_planes, depsgraph=depsgraph) + batch = cls._build_cap_batch(verts) if verts else None + cls._cap_cache[obj.name] = (cache_key, batch) + + for name in list(cls._cap_cache): + if name not in live_names: + cls._cap_cache.pop(name) + for name in list(cls._last_seen_object_matrices): + if name not in live_names: + cls._last_seen_object_matrices.pop(name) + + @staticmethod + def _build_cap_batch(verts: list[tuple[float, float, float]]): + """Bake ``verts`` into a GPU ``TRIS`` batch bound to ``UNIFORM_COLOR``.""" + import gpu + from gpu_extras.batch import batch_for_shader + + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return batch_for_shader(shader, "TRIS", {"pos": verts}) + + @classmethod + def _triangulate_cap_faces(cls, cap_faces, mw) -> list[tuple[float, float, float]]: + """Triangulate cap faces and return world-space triangle vertices. + + Each cap face is tessellated as a single simple ring via + :meth:`tool.Cad.tessellate_ring_planar`. Nested cap polygons + (hollow profiles — annular columns, pipe walls) render as solid + discs in v1; proper polygon-with-holes triangulation is a known + limitation and a follow-up. + """ + verts: list[tuple[float, float, float]] = [] + for face in cap_faces: + if not face.is_valid or len(face.verts) < 3: + continue + ring = [v.co.copy() for v in face.verts] + try: + tri_indices = tool.Cad.tessellate_ring_planar([ring]) + except Exception: + continue + for i, j, k in tri_indices: + for idx in (i, j, k): + w = mw @ ring[idx] + verts.append((w.x, w.y, w.z)) + return verts + + @classmethod + def on_depsgraph_update_caps(cls, scene, depsgraph) -> None: + """Depsgraph entry-point — guard, then delegate to the + modal-aware debounce in :meth:`_handle_cap_tick`.""" + if getattr(bpy.context, "screen", None) is None: + return + if cls._active_scene_props(scene) is None: + return + # Edit mode (mesh / curve / armature / …) fires depsgraph + # constantly as the user manipulates verts/edges; the cap view + # isn't the focus of that work, and the caps would flash off on + # every nudge. Skip scheduling entirely while in any edit mode. + if tool.Blender.is_in_edit_mode(): + return + cls._handle_cap_tick(scene, depsgraph) + + @classmethod + def _handle_cap_tick(cls, scene, depsgraph) -> None: + """Schedule (or immediately fire) a cap-cache rebuild. + + Strategy: + - Default: debounce. Each depsgraph tick reschedules a + ``bpy.app.timers`` callback ``_CAP_REBUILD_DEBOUNCE_SECONDS`` + in the future, so a burst of ticks from an unknown-to-Bonsai + drag (external-addon gizmo, scripted property updates) collapses + to a single rebuild after the storm subsides. Drag is smooth, + caps catch up shortly after release. + - Fast path: when a *known* transform modal (Bonsai G/R/S) just + finished — detected as a True→False transition on + ``is_transform_modal_active`` — cancel any pending timer and + rebuild immediately, preserving the snappy on-release feel for + Bonsai-internal drags. + - Skip path: depsgraph ticks fire for selection-only changes, + UI events, undo writes, etc. — none of which can move a cap. + When no update in the tick carries ``is_updated_geometry`` or + ``is_updated_transform``, return without scheduling so the + cache and its hide-while-pending gate don't churn for free. + """ + is_modal = tool.Blender.is_transform_modal_active(bpy.context) + modal_just_ended = cls._last_modal_state and not is_modal + cls._last_modal_state = is_modal + + if modal_just_ended: + cls._cancel_pending_cap_rebuild() + cls.rebuild_cap_cache(scene, depsgraph=depsgraph) + return + + if depsgraph is not None and not cls._depsgraph_has_relevant_changes(depsgraph): + return + + cls._schedule_cap_rebuild() + + @classmethod + def _depsgraph_has_relevant_changes(cls, depsgraph) -> bool: + """True iff the tick carries an Object geometry change, or an + Object transform update whose ``matrix_world`` actually moved. + + Blender raises ``is_updated_transform`` on the selected Object + itself even for plain selection changes (no matrix delta), and + on Scene / ViewLayer IDs for the same. We'd schedule (and hide + caps for) every click without this check. Comparing a matrix + hash against a per-object baseline filters selection noise + without requiring opt-in from external addons. + + First time we see an Object the hash is recorded as baseline + (no flag), so an addon-load-time selection burst doesn't fire + a phantom rebuild; subsequent real moves are detected on the + first tick the matrix actually differs. + """ + relevant = False + for upd in depsgraph.updates: + obj = upd.id + if not isinstance(obj, bpy.types.Object): + continue + if upd.is_updated_geometry: + relevant = True + continue + if not upd.is_updated_transform: + continue + new_hash = tool.Blender.hash_matrix(obj.matrix_world) + old_hash = cls._last_seen_object_matrices.get(obj.name) + cls._last_seen_object_matrices[obj.name] = new_hash + if old_hash is not None and old_hash != new_hash: + relevant = True + return relevant + + @classmethod + def _schedule_cap_rebuild(cls) -> None: + """(Re)schedule the deferred cap rebuild. + + Each call cancels any pending timer and registers a fresh one + so a burst of updates collapses to a single rebuild once the + debounce window of quiet elapses. + """ + cls._cancel_pending_cap_rebuild() + + def _do_rebuild() -> None: + cls._pending_cap_rebuild = None + try: + cls.rebuild_cap_cache() + except Exception: + # bpy.app.timers swallows exceptions silently, leaving + # the user with stale caps + no diagnostic. Surface to + # the console so future bisect / cap edge cases are + # debuggable instead of mysteriously invisible. + import traceback + + traceback.print_exc() + # Timer fires from the main loop without an accompanying + # depsgraph tick, so the viewport won't repaint on its own; + # nudge every region so the freshly-baked cap batches show + # up without the user having to wiggle the mouse. + for _area, region, _region_3d in tool.Blender.iter_view3d_regions(): + region.tag_redraw() + return None + + bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS) + cls._pending_cap_rebuild = _do_rebuild + + @classmethod + def _cancel_pending_cap_rebuild(cls) -> None: + """Cancel any pending debounced rebuild so the next event source + gets a clean slate. Idempotent and safe to call when none is + registered (e.g. on addon unregister).""" + pending = cls._pending_cap_rebuild + if pending is not None and bpy.app.timers.is_registered(pending): + bpy.app.timers.unregister(pending) + cls._pending_cap_rebuild = None + + @classmethod + def on_post_view_caps(cls) -> None: + """Draw cached cap batches over the clipped geometry. + + Installed as a ``SpaceView3D.draw_handler_add`` at ``POST_VIEW``. + Caps render with depth-test + depth-write enabled so any + geometry in front of the cap occludes it — without this the + ``UNIFORM_COLOR`` shader defaults to no-depth and the caps + would always paint on top of the scene. One ``batch.draw`` per + object; batches are pre-baked. + """ + if not cls._cap_cache: + return + scene_props = cls._active_scene_props() + if scene_props is None or not scene_props.show_caps: + return + # Hide caps while in edit mode — the user's focus is on + # vert/edge/face manipulation, not the section view; the cache + # is also frozen by the same gate in the depsgraph path. + if tool.Blender.is_in_edit_mode(): + return + # Hide caps for the duration of any G/R/S to suppress mid-drag + # visual jitter; the cache is also frozen by the same gate so + # anything drawn here would be stale relative to the live mesh. + if tool.Blender.is_transform_modal_active(bpy.context): + return + # Hide caps while a debounced rebuild is in flight (typical + # cause: external-addon gizmo drag). The cache may reflect a + # frame from earlier in the drag; drawing it would look stale + # against the geometry the user is currently mutating. + if cls._pending_cap_rebuild is not None: + return + import gpu + + prefs = tool.Blender.get_addon_preferences() + cap_color = tuple(prefs.clip_box_cap_color) + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + shader.bind() + shader.uniform_float("color", cap_color) + prev_depth_test = gpu.state.depth_test_get() + prev_depth_mask = gpu.state.depth_mask_get() + gpu.state.depth_test_set("LESS_EQUAL") + gpu.state.depth_mask_set(True) + try: + for _key, batch in cls._cap_cache.values(): + if batch is None: + continue + batch.draw(shader) + finally: + gpu.state.depth_mask_set(prev_depth_mask) + gpu.state.depth_test_set(prev_depth_test) diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py new file mode 100644 index 0000000000..4ec154b573 --- /dev/null +++ b/src/bonsai/bonsai/tool/connection.py @@ -0,0 +1,150 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Generic discovery of the relation linking two IFC elements. + +Used by ``bim.disconnect_elements`` so the operator surface is one operator +per disconnect intent (active vs. partner, identified by GlobalId) rather +than one per rel class. The kind label returned alongside the rel lets the +operator dispatch the right post-disconnect cleanup: + +- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.) +- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"`` + (the rel kind ``extend_walls_to_underside`` creates) +- ``"element"`` for any other ``IfcRelConnectsElements`` + +Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect +operator's cleanup switch maps each kind to the right post-mutation calls.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import ifcopenshell + + +class Connection: + @classmethod + def find_rels( + cls, + elem_a: "ifcopenshell.entity_instance", + elem_b: "ifcopenshell.entity_instance", + ) -> "list[tuple[ifcopenshell.entity_instance, str]]": + """Return every supported rel linking ``elem_a`` to ``elem_b`` as a + list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and + ``ConnectedFrom`` because either side of the rel can be the relating + element, and the same pair may carry rels authored with opposite + orientations (``disconnect_path``'s ``(relating, related)`` mode only + inspects ``relating.ConnectedTo``, so a single call would miss the + opposite-orientation rel).""" + rels: list[tuple[ifcopenshell.entity_instance, str]] = [] + seen: set[int] = set() + + def _record(rel, kind): + if rel.id() not in seen: + seen.add(rel.id()) + rels.append((rel, kind)) + + for rel in getattr(elem_a, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatedElement", None) == elem_b: + _record(rel, "path") + for rel in getattr(elem_a, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatingElement", None) == elem_b: + _record(rel, "path") + + for rel in getattr(elem_a, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatingElement", None) == elem_b: + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind) + for rel in getattr(elem_a, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatedElement", None) == elem_b: + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind) + + return rels + + @classmethod + def find_rel( + cls, + elem_a: "ifcopenshell.entity_instance", + elem_b: "ifcopenshell.entity_instance", + ) -> "tuple[ifcopenshell.entity_instance | None, str | None]": + """Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than + ``find_rels`` when callers only need to know whether a connection + exists or what kind it is.""" + rels = cls.find_rels(elem_a, elem_b) + return rels[0] if rels else (None, None) + + @classmethod + def find_rels_for_element( + cls, + elem: "ifcopenshell.entity_instance", + ) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]": + """Return every supported rel touching ``elem`` as ``(rel, kind, partner)`` + triples. ``partner`` is the *other* element on the rel — the side cascade + cleanup must operate on when ``elem`` is being deleted. + + Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry + point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object`` + enumerate everything the disconnect operator would handle pairwise. + """ + result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = [] + seen: set[int] = set() + + def _record(rel, kind, partner): + if partner is None or rel.id() in seen: + return + seen.add(rel.id()) + result.append((rel, kind, partner)) + + for rel in getattr(elem, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + _record(rel, "path", getattr(rel, "RelatedElement", None)) + elif rel.is_a("IfcRelConnectsElements"): + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind, getattr(rel, "RelatedElement", None)) + for rel in getattr(elem, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + _record(rel, "path", getattr(rel, "RelatingElement", None)) + elif rel.is_a("IfcRelConnectsElements"): + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind, getattr(rel, "RelatingElement", None)) + + return result + + @classmethod + def orient_element_top( + cls, + rel: "ifcopenshell.entity_instance", + elem_a: "ifcopenshell.entity_instance", + elem_b: "ifcopenshell.entity_instance", + ) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]": + """Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel. + + The ``extend_walls_to_underside`` flow stores slab as the relating + side and wall as related — orientation is recovered by checking + which input matches which rel attribute. Callers pass any two + elements; this resolves which is the wall and which is the slab so + post-disconnect cleanup (regenerate-wall-to-underside) targets the + right object.""" + if getattr(rel, "RelatingElement", None) == elem_a: + return elem_b, elem_a + return elem_a, elem_b diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index e0b719f065..911c9990fb 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -65,6 +65,7 @@ from typing_extensions import TypeIs import bonsai.bim.helper import bonsai.bim.import_ifc +import bonsai.core.connection import bonsai.core.drawing import bonsai.core.geometry import bonsai.core.root @@ -156,6 +157,112 @@ class Geometry(bonsai.core.tool.Geometry): for modifier in obj.modifiers: obj.modifiers.remove(modifier) + @classmethod + def _group_edges_into_loops(cls, edges) -> list[list]: + """Group an edge set into connected components by shared vertices. + + Each returned group is a list of edges that share at least one + vertex chain. A hollow profile's bisect produces two disjoint + loops (outer ring + inner ring) — grouping splits them so each + can be filled independently as a separate cap face, rather than + ``contextual_create`` welding them into one solid outer face + with the inner loop demoted to interior decoration. + """ + edge_set = set(edges) + visited: set = set() + groups: list[list] = [] + for start in edges: + if start in visited: + continue + group: list = [] + stack: list = [start] + while stack: + e = stack.pop() + if e in visited: + continue + visited.add(e) + group.append(e) + for v in e.verts: + for adj in v.link_edges: + if adj in edge_set and adj not in visited: + stack.append(adj) + groups.append(group) + return groups + + @classmethod + def bisect_and_cap( + cls, + bm, + planes_local, + *, + tag_layer_name: str = "bbim_cap", + dist: float = 1e-4, + weld_dist: float = 1e-5, + ): + """Clip ``bm`` against each ``(plane_co, plane_no)`` and fill the cuts. + + Per plane, ``bmesh.ops.bisect_plane(clear_outer=True)`` discards + the outside half-space and ``bmesh.ops.contextual_create`` fills + the resulting cut edges with cap faces tagged via a BMesh int + layer so the tag propagates to any split-children from subsequent + planes. After all planes, near-coincident vertices are welded + (``weld_dist``) so adjacent caps from the same cross-section + merge cleanly. + + Callers are responsible for input mesh quality. Non-watertight + inputs (terrain, single-shell surfaces) may produce degenerate + cap faces; that's an accepted user-supplied data limitation. + + Returns the cap-tag BMLayerItem, or ``None`` if ``bm`` is empty. + """ + import bmesh + + if not bm.faces: + return None + + # Pre-weld nearby verts: T-junctions in messy IFC meshes (a third + # vertex sitting in the middle of an edge from a Boolean + # operation) make the bisect cut terminate early, leaving open + # loops that no fill op can close. Welding the T-junction's + # near-coincident vertex into the host edge before bisecting + # turns the cut into a closed loop. + bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=max(weld_dist, 1e-4)) + + cap_layer = bm.faces.layers.int.new(tag_layer_name) + for plane_co, plane_no in planes_local: + geom = bm.verts[:] + bm.edges[:] + bm.faces[:] + if not geom: + break + results = bmesh.ops.bisect_plane( + bm, + geom=geom, + dist=dist, + plane_co=plane_co, + plane_no=plane_no, + clear_outer=True, + ) + cut_edges = [e for e in results["geom_cut"] if isinstance(e, bmesh.types.BMEdge)] + if not cut_edges: + continue + # Group cut edges into connected components BEFORE filling. + # Feeding ``contextual_create`` all edges at once (outer + + # inner of a hollow profile) makes it create a SINGLE outer + # face and treat inner edges as decoration — collapsing the + # hole. Filling each connected loop separately produces one + # cap face per ring. + for loop_edges in cls._group_edges_into_loops(cut_edges): + try: + fill = bmesh.ops.contextual_create(bm, geom=loop_edges) + except (RuntimeError, TypeError): + continue + for f in fill.get("faces", []): + if isinstance(f, bmesh.types.BMFace) and f.is_valid: + f[cap_layer] = 1 + + if weld_dist > 0.0: + bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=weld_dist) + return cap_layer + @classmethod def clear_scale(cls, obj: bpy.types.Object) -> None: """Apply and clear object scale. @@ -271,12 +378,38 @@ class Geometry(bonsai.core.tool.Geometry): bpy.data.objects.remove(obj) @classmethod - def delete_ifc_object(cls, obj: bpy.types.Object) -> None: + def delete_ifc_object( + cls, + obj: bpy.types.Object, + batch_being_deleted_ids: Optional[set[int]] = None, + ) -> None: ifc_file = tool.Ifc.get() element = tool.Ifc.get_entity(obj) if not element: return - elif element.is_a("IfcAnnotation"): + # Cascade connection-rel teardown — symmetric to bim.disconnect_elements. + # When a slab connected to a wall via IfcRelConnectsElements(TOP) is deleted, + # the wall's trim booleans + BBIM_Boolean pset would otherwise be orphaned. + # skip_elem_recreate is always True here because we're inside delete: the + # element is about to vanish, so re-extruding it would be wasted work. + # skip_partner_recreate fires only when the partner is also queued in the + # same OverrideDelete batch. + if element.is_a("IfcRoot"): + skip_ids = batch_being_deleted_ids or set() + for rel, kind, partner in tool.Connection.find_rels_for_element(element): + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=rel, + kind=kind, + elem=element, + partner=partner, + skip_elem_recreate=True, + skip_partner_recreate=(partner.id() in skip_ids), + ) + if element.is_a("IfcAnnotation"): if element.ObjectType == "DRAWING": return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element) elif tool.Drawing.is_auto_annotation(element): @@ -641,7 +774,13 @@ class Geometry(bonsai.core.tool.Geometry): and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) ): - return tool.Ifc.get().by_id(ifc_id) + try: + return tool.Ifc.get().by_id(ifc_id) + except RuntimeError: + # Stale id: a representation rebuild freed the old entity + # while obj.data still tracks its id. Treated as "no active + # representation" — same contract as a mesh with id 0. + return None @classmethod def get_data_representation(cls, data: bpy.types.ID) -> ifcopenshell.entity_instance | None: @@ -2348,6 +2487,21 @@ class Geometry(bonsai.core.tool.Geometry): old_to_new[element] = [new] if new.is_a("IfcRelSpaceBoundary"): tool.Boundary.decorate_boundary(new_obj) + # Slab-trim booleans (from extend_walls_to_underside) belong to + # the source wall's connection, not the copy. Strip them so the + # duplicate reverts to its pre-clip extrusion — mirrors the way + # filling rels are dropped while manual booleans persist on copy. + # Reload the body when something was stripped so the viewport + # immediately shows the unclipped geometry; otherwise the user + # sees a stale mesh until they Shift+G, which is easy to miss. + if new.is_a("IfcWall"): + if tool.Model.strip_underside_booleans(new): + tool.Model.reload_body_representation(new_obj) + # HasOpenings rels don't follow object duplication, so + # the duplicate's body must rebuild to match its current + # opening set. + else: + tool.Model.regenerate_wall(new_obj) # Remap Blender parent relationships for duplicated objects for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items(): @@ -2430,7 +2584,10 @@ class Geometry(bonsai.core.tool.Geometry): pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") if not pset: continue - array_parents.add(tool.Ifc.get().by_guid(pset["Parent"])) + try: + array_parents.add(tool.Ifc.get().by_guid(pset["Parent"])) + except RuntimeError: + continue for array_parent in array_parents: array_parent_obj = tool.Ifc.get_object(array_parent) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2ea1678479..137bed3371 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -82,6 +82,7 @@ if TYPE_CHECKING: BIMPolylineProperties, BIMRailingProperties, BIMRoofProperties, + BIMSlabProperties, BIMStairProperties, BIMSverchokProperties, BIMWallProperties, @@ -118,6 +119,10 @@ class Model(bonsai.core.tool.Model): def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def get_slab_props(cls, obj: bpy.types.Object) -> BIMSlabProperties: + return obj.BIMSlabProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties: return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue] @@ -809,7 +814,7 @@ class Model(bonsai.core.tool.Model): unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) layer_params = tool.Model.get_material_layer_parameters(element) layer_offset = layer_params["offset"] - thickness = layer_params["thickness"] / unit_scale + thickness = layer_params["thickness"] props = tool.Material.get_object_material_props(obj) # Try to load from pset if not already in props @@ -817,7 +822,7 @@ class Model(bonsai.core.tool.Model): pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") if pset and pset.get("UseCustomOffset", False): # Load from pset - custom_offset = pset.get("CustomOffset", 0.0) + custom_offset = pset.get("CustomOffset", 0.0) * unit_scale usage_type = tool.Model.get_usage_type(element) if usage_type == "LAYER2": @@ -830,7 +835,7 @@ class Model(bonsai.core.tool.Model): return None else: # Use current props - custom_offset = props.custom_offset / unit_scale + custom_offset = props.custom_offset if tool.Model.get_usage_type(element) == "LAYER2": custom_offset_reference = props.custom_wall_reference elif tool.Model.get_usage_type(element) == "LAYER3": @@ -841,17 +846,17 @@ class Model(bonsai.core.tool.Model): direction_sense = layer_params["direction_sense"] if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}: - layer_offset = custom_offset - thickness * unit_scale + layer_offset = custom_offset - thickness if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: - layer_offset = custom_offset - (thickness / 2) * unit_scale + layer_offset = custom_offset - (thickness / 2) if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or ( direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"} ): layer_offset = custom_offset if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: - layer_offset = custom_offset + (thickness / 2) * unit_scale + layer_offset = custom_offset + (thickness / 2) if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}: - layer_offset = custom_offset + thickness * unit_scale + layer_offset = custom_offset + thickness return layer_offset / unit_scale @@ -904,6 +909,46 @@ class Model(bonsai.core.tool.Model): """Return True if element has an IfcRelConnectsElements(TOP) relationship.""" return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom) + @classmethod + def strip_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> bool: + """Remove slab-trim ``IfcBooleanResult`` items from a wall's body chain. + + Returns ``True`` if any boolean was removed, so the caller knows whether + a Blender-side body reload is needed to surface the geometry change. + + Hook for the duplicate path (Shift+D): the source wall's clip booleans + don't make sense on a copy pulled away from the slab. Booleans whose + ``SecondOperand.is_a("IfcTessellatedFaceSet")`` are removed — same + imprecise discriminator the rest of the wall-to-underside machinery + uses (manual cuts authored from tessellated meshes would also be + stripped, but most manual cuts use ``IfcExtrudedAreaSolid`` / CSG + primitives and are unaffected). + + Cannot reuse ``remove_wall_to_underside_booleans`` here because the + duplicate's ``BBIM_Boolean.Data`` holds the source wall's stale ids — + ``get_manual_booleans`` returns empty on the copy and the helper + early-returns. The duplicate hook works directly off the chain. + """ + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return False + chain = cls.get_booleans(wall, representation) + to_remove = [b for b in chain if (sec := b.SecondOperand) is not None and sec.is_a("IfcTessellatedFaceSet")] + for b in to_remove: + tool.Geometry.remove_representation_item(b.SecondOperand, wall) + # Sweep the now-stale BBIM_Boolean entries on the copy (their ids point + # at booleans that were never in this wall's chain — they survived the + # ifcopenshell deep copy as JSON text in the pset payload). + pset_data = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + if pset_data: + representation = tool.Geometry.get_body_representation(wall) + chain_ids = {b.id() for b in cls.get_booleans(wall, representation)} if representation else set() + stored_ids = set(json.loads(pset_data["Data"])) + stale_ids = stored_ids - chain_ids + if stale_ids: + cls.unmark_manual_booleans(wall, list(stale_ids)) + return bool(to_remove) + @classmethod def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None: """Remove all IfcBooleanResult items previously added by extend_walls_to_underside.""" @@ -1275,7 +1320,10 @@ class Model(bonsai.core.tool.Model): # handle elements unused in the array after regeneration removed_children = set(existing_children) - set(array["children"]) for removed_child in removed_children: - element = tool.Ifc.get().by_guid(removed_child) + try: + element = tool.Ifc.get().by_guid(removed_child) + except RuntimeError: + continue # Strip any wall/slab opening cut by this child before deletion, # so the host's HasOpenings shrinks symmetrically with count. if getattr(element, "FillsVoids", None): @@ -3024,6 +3072,19 @@ class Model(bonsai.core.tool.Model): obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix) tool.Geometry.record_object_position(obj) + @classmethod + def regenerate_wall(cls, obj: bpy.types.Object) -> None: + """Rebuild a wall's body from current IFC state: extrusion + openings + first, then re-clip to any surviving ``IfcRelConnectsElements(TOP)`` + slab. Safe on walls with no openings and no slab connection — both + steps no-op against their preconditions.""" + element = tool.Ifc.get_entity(obj) + if element is None: + return + cls.recreate_wall(element, obj) + if cls.has_underside_connection(element): + bonsai.core.model.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, cls, [obj]) + @classmethod def recalculate_walls(cls, walls: list[bpy.types.Object]) -> None: queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set() diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index 3c3fba66f5..313133f192 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -157,6 +157,7 @@ class Parametric(bonsai.core.tool.Parametric): ParametricObject("pipe_segment", supports_build_edit_lifecycle=True), ParametricObject("duct_segment", supports_build_edit_lifecycle=True), ParametricObject("wall"), + ParametricObject("slab"), ] # Annotations for the uppercase constants populated from ``EDIT_TYPES`` by @@ -171,6 +172,7 @@ class Parametric(bonsai.core.tool.Parametric): PIPE_SEGMENT: ClassVar[ParametricObject] DUCT_SEGMENT: ClassVar[ParametricObject] WALL: ClassVar[ParametricObject] + SLAB: ClassVar[ParametricObject] _geom_generation: int = 0 @@ -459,6 +461,15 @@ class Parametric(bonsai.core.tool.Parametric): return False return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None + @classmethod + def is_slab(cls, element: entity_instance) -> bool: + """``True`` for any ``IfcSlab``. The slab edit lifecycle only gates + the connection-disconnect UI — no IFC mutation — so we don't narrow + further (e.g. by checking for wall connections). Per-gizmo polls + layer the "has wall connections" check on top via + ``tool.Wall.iter_slab_wall_connections``.""" + return element is not None and element.is_a("IfcSlab") + @classmethod def is_wall(cls, element: entity_instance) -> bool: """A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage. diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py index c982b15371..b3c850e790 100644 --- a/src/bonsai/bonsai/tool/wall.py +++ b/src/bonsai/bonsai/tool/wall.py @@ -242,6 +242,75 @@ class Wall(bonsai.core.tool.Wall): local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0)) return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2 + @classmethod + def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance): + """Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)`` + connecting a slab to this wall — the rel kind ``extend_walls_to_underside`` + creates. Walks ``wall.ConnectedFrom`` because the slab is the relating + side of the TOP rel.""" + for rel in getattr(wall, "ConnectedFrom", []) or (): + if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP": + continue + slab = rel.RelatingElement + if slab is None: + continue + yield slab, rel + + @classmethod + def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance): + """Yield ``(wall, rel)`` tuples for every wall clipped to this slab's + underside. Mirror of ``iter_wall_slab_connections`` from the slab side + — walks ``slab.ConnectedTo``.""" + for rel in getattr(slab, "ConnectedTo", []) or (): + if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP": + continue + wall = rel.RelatedElement + if wall is None: + continue + yield wall, rel + + @classmethod + def find_wall_slab_rel( + cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance + ) -> ifcopenshell.entity_instance | None: + """Return the single ``IfcRelConnectsElements(TOP)`` between ``wall`` + and ``slab``, or ``None`` if none exists. Used by the disconnect + operator to find the specific rel to remove.""" + for s, rel in cls.iter_wall_slab_connections(wall): + if s == slab: + return rel + return None + + WALL_SLAB_CONNECTION_Z_CLEARANCE = 0.5 + """Lift above the wall top so the disconnect icon sits above the + extend-vertical / slope gizmo and reads as "the thing above the wall = + the slab connection".""" + + @classmethod + def wall_slab_connection_location_world( + cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object + ) -> Vector | None: + """World-space anchor for the wall-slab disconnect icon. + + X / Y come from the wall axis midpoint (so the icon sits in the + middle of the wall horizontally); Z is the wall's top in world space + plus ``WALL_SLAB_CONNECTION_Z_CLEARANCE`` so the icon perches above + the slope gizmo. The slab-side gizmo calls this with the same + arguments so both sides of the same connection render a single + visual marker. ``slab_obj`` is kept on the signature for the + symmetric call shape; the helper's body no longer reads from it. + Returns ``None`` when the wall has no reference line.""" + ref = cls.get_world_reference_line(wall_obj) + if ref is None: + return None + axis_mid_world = (ref[0] + ref[1]) * 0.5 + if wall_obj.bound_box: + wall_top_local_z = max(c[2] for c in wall_obj.bound_box) + wall_top_world_z = (wall_obj.matrix_world @ Vector((0.0, 0.0, wall_top_local_z))).z + else: + wall_top_world_z = axis_mid_world.z + return Vector((axis_mid_world.x, axis_mid_world.y, wall_top_world_z + cls.WALL_SLAB_CONNECTION_Z_CLEARANCE)) + @classmethod def walk_connected_walls( cls, diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index e628606201..f4dbb884b6 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -8,6 +8,7 @@ markers = brick bsdd classification + clip_box context cost covering diff --git a/src/bonsai/test/bim/conftest.py b/src/bonsai/test/bim/conftest.py index 2d69fe415a..a957b479ba 100644 --- a/src/bonsai/test/bim/conftest.py +++ b/src/bonsai/test/bim/conftest.py @@ -1,5 +1,46 @@ import pytest + +class _FakePropsBase: + """Base for parametric-edit PropertyGroup stand-ins used in lifecycle tests. + + The parametric-edit lifecycle mixins read/write a common contract: + ``is_editing`` (bool), ``last_kwargs`` (dict | None — capture of the last + data written via ``set_props_kwargs_from_ifc_data``), + ``set_props_kwargs_from_ifc_data(data)``, and + ``get_general_kwargs(convert_to_project_units=True)``. Per-type stand-ins + (door, railing, roof) subclass this and add their own kwargs accessors + and per-type fields.""" + + def __init__(self, general: dict | None = None): + self.is_editing = False + self.last_kwargs: dict | None = None + self.general = dict(general) if general is not None else {} + + def set_props_kwargs_from_ifc_data(self, data): + self.last_kwargs = dict(data) + + def get_general_kwargs(self, convert_to_project_units=True): + return dict(self.general) + + +def make_lifecycle_obj(props, *, name="obj"): + """Build a ``bpy.types.Object`` stand-in for parametric-lifecycle tests. + + The mixin code under test reads ``obj.props`` (the PropertyGroup + stand-in) and ``obj.name`` (used in error reports). ``spec=bpy.types.Object`` + catches typo'd attribute access at test time. ``bpy`` is imported inside + the function so this conftest stays importable when bpy is absent.""" + from unittest import mock + + import bpy + + obj = mock.Mock(spec=bpy.types.Object, name=name) + obj.props = props + obj.name = name + return obj + + # pytest by default doesn't print steps and where it failed. Let's fix that. diff --git a/src/bonsai/test/bim/module/clip_box/__init__.py b/src/bonsai/test/bim/module/clip_box/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_box.py b/src/bonsai/test/bim/module/clip_box/test_clip_box.py new file mode 100644 index 0000000000..92a51e6390 --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_clip_box.py @@ -0,0 +1,692 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import math +from unittest.mock import patch + +import bpy +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +class TestAddClipBox(NewFile): + def test_creates_empty_and_registers_entry(self): + result = bpy.ops.bim.add_clip_box() + assert result == {"FINISHED"} + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + host = scene_props.clip_boxes[0].obj + assert host is not None + assert host.empty_display_type == "CUBE" + obj_props = tool.ClipBox.get_object_props(host) + assert obj_props.is_clip_box is True + + def test_spawns_at_3d_cursor(self): + bpy.context.scene.cursor.location = (4.0, 0.0, 2.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert host is not None + assert host.matrix_world.translation.x == pytest.approx(4.0) + assert host.matrix_world.translation.z == pytest.approx(2.0) + + def test_spawns_in_clip_boxes_collection(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + collection_names = [c.name for c in host.users_collection] + assert "BBIM_ClipBoxes" in collection_names + + +class TestActiveClipBoxResolution(NewFile): + def test_no_box_returns_none(self): + assert tool.ClipBox.get_active_clip_box() is None + + def test_active_index_out_of_range_returns_none(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.active_clip_box_index = 99 + assert tool.ClipBox.get_active_clip_box() is None + + +class TestComputePlanes(NewFile): + def test_planes_match_unit_box_at_origin(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, -2))) + + def test_scaled_host_grows_clip_region(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0)) + + # Test points well clear of any reasonable expand margin so the + # assertion pins the OBB scaling behaviour, not the margin value. + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((2.5, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((4.0, 0, 0))) + + def test_rotated_host_rotates_clip_region(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") + + # Test points well clear of the expand margin so the assertion + # pins rotation, not the margin value. + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0.5, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2.0, 0, 0))) + + def test_translated_and_rotated_host_keeps_centre_inside(self): + bpy.context.scene.cursor.location = (5.0, 7.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Translation((5.0, 7.0, 0.0)) @ Matrix.Rotation(math.radians(30), 4, "Z") + + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0))) + + +class TestToggleEnabled(NewFile): + def test_flips_scene_enabled_flag(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + original = scene_props.enabled + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is (not original) + + +class TestSetActiveClipBox(NewFile): + def test_switches_active_index(self): + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.active_clip_box_index == 1 + bpy.ops.bim.set_active_clip_box(index=0) + assert scene_props.active_clip_box_index == 0 + + def test_invalid_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.set_active_clip_box(index=99) + assert result == {"CANCELLED"} + + +class TestRemoveClipBox(NewFile): + def test_drops_active_entry_when_no_index(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host_name = host.name + bpy.ops.bim.remove_clip_box(delete_object=True) + assert host_name not in bpy.data.objects + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 0 + + def test_drops_specified_index(self): + # Per-row UIList button passes index explicitly; the user can + # click X on any row without first selecting it as active. + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + first_name = scene_props.clip_boxes[0].obj.name + bpy.ops.bim.remove_clip_box(index=0) + assert first_name not in bpy.data.objects + assert len(scene_props.clip_boxes) == 1 + + def test_no_active_box_cancels(self): + result = bpy.ops.bim.remove_clip_box() + assert result == {"CANCELLED"} + + def test_out_of_range_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.remove_clip_box(index=99) + assert result == {"CANCELLED"} + + +class TestPsetPersistence(NewFile): + def test_add_clip_box_writes_project_pset(self): + import ifcopenshell.util.element + + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + project = tool.Ifc.get().by_type("IfcProject")[0] + psets = ifcopenshell.util.element.get_psets(project) + assert tool.ClipBox.PSET_NAME in psets + assert psets[tool.ClipBox.PSET_NAME]["Count"] == 1 + + def test_round_trip_via_pset_restores_matrix(self): + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)) + tool.ClipBox.save_to_project_pset() + + # Simulate a fresh-load state: clear scene list AND delete the + # Blender empty so load has to recreate it. + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_boxes.clear() + bpy.data.objects.remove(host, do_unlink=True) + + tool.ClipBox.load_from_project_pset() + assert len(scene_props.clip_boxes) == 1 + rehydrated = scene_props.clip_boxes[0].obj + assert rehydrated is not None + for r in range(4): + for c in range(4): + expected = (Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)))[r][c] + assert rehydrated.matrix_world[r][c] == pytest.approx(expected, abs=1e-6) + + def test_load_from_pset_is_idempotent(self): + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + tool.ClipBox.load_from_project_pset() + tool.ClipBox.load_from_project_pset() + assert len(tool.ClipBox.get_scene_props().clip_boxes) == 1 + + def test_load_from_pset_does_not_touch_enabled(self): + # ``enabled`` is intentionally not persisted to the pset — the + # default is False (fresh .blend) and Blender's own .blend + # session save carries the user's saved value through reload. + # ``load_from_project_pset`` must not stomp either. + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = True + tool.ClipBox.save_to_project_pset() + + # Simulate the depsgraph IFC-reload branch: load runs without + # touching enabled; the prior True value must survive. + tool.ClipBox.load_from_project_pset() + assert scene_props.enabled is True + + # And the opposite: load when False must not flip it True. + scene_props.enabled = False + tool.ClipBox.load_from_project_pset() + assert scene_props.enabled is False + + def test_add_clip_box_creates_no_ifc_entity(self): + # The clip box is project-pset persisted; there must be no + # IfcRoot entity attached to the empty (which would lock its + # scale and strip it on export). + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert tool.Ifc.get_entity(host) is None + + def test_show_caps_round_trips_via_pset(self): + # show_caps is a scene-level toggle persisted in the project pset. + # Per-mesh cap cost dominates the bisect, which doesn't scale with + # clip-box extent, so the toggle applies file-wide. + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.show_caps = False + tool.ClipBox.save_to_project_pset() + + scene_props.clip_boxes.clear() + scene_props.show_caps = True # default; load_from_project_pset must flip back to False + bpy.data.objects.remove(host, do_unlink=True) + + tool.ClipBox.load_from_project_pset() + assert scene_props.show_caps is False + + +class TestCapGeneration(NewFile): + def test_cap_for_box_straddling_clip_plane_produces_triangles(self): + # A 2x2x2 cube centred at the origin, clipped by a unit-radius clip + # box also at the origin: the four faces of the cube that pierce + # the +/- x box faces should yield cap polygons on the two faces. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) # unit box at origin + + bpy.ops.mesh.primitive_cube_add(size=4.0, location=(0.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + + # Every cap is at least one triangle (3 verts each), and we expect + # 6 cap polygons (one per box face) → at minimum 18 verts. + assert len(verts) >= 18 + assert len(verts) % 3 == 0 + + def test_cap_for_mesh_entirely_outside_box_produces_nothing(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_cube_add(size=1.0, location=(10.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + assert verts == [] + + def test_cap_for_mesh_entirely_inside_box_produces_nothing(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_cube_add(size=0.5, location=(0.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + assert verts == [] + + def test_non_watertight_mesh_does_not_crash(self): + # The cap pipeline assumes watertight input; non-watertight + # meshes (terrain, single-shell surfaces) may produce degenerate + # caps but must not raise. The user is responsible for input + # quality. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_grid_add(size=4.0, location=(0.0, 0.0, 0.0)) + grid = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(grid, world_planes) + assert isinstance(verts, list) + + def test_show_caps_defaults_on_and_toggles(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.show_caps is True + scene_props.show_caps = False + assert scene_props.show_caps is False + + +class TestCapEligibility(NewFile): + def test_ifc_space_is_not_capped(self): + # IfcSpace is an IfcProduct (spatial structure) but should never + # cap — spaces are non-physical containers; capping them sprouts + # solid fills where the room boundary crosses the clip plane. + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + space_obj = bpy.context.active_object + tool.Root.get_root_props().ifc_product = "IfcSpatialElement" + bpy.ops.bim.assign_class(ifc_class="IfcSpace") + + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert space_obj not in capable + + def test_ifc_wall_is_capped(self): + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + wall_obj = bpy.context.active_object + tool.Root.get_root_props().ifc_product = "IfcElement" + bpy.ops.bim.assign_class(ifc_class="IfcWall") + + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert wall_obj in capable + + def test_pure_blender_mesh_is_not_capped(self): + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + cube = bpy.context.active_object + # No assign_class — pure Blender mesh, no IFC entity attached. + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert cube not in capable + + +class TestDuplicateClipBox(NewFile): + def test_duplicates_active_when_no_index(self): + bpy.ops.bim.add_clip_box() + source = tool.ClipBox.get_active_clip_box() + source.matrix_world = Matrix.Translation((3.0, 4.0, 5.0)) @ Matrix.Diagonal((2.0, 1.0, 1.0, 1.0)) + + bpy.ops.bim.duplicate_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 2 + copy = tool.ClipBox.get_active_clip_box() + assert copy is not source + for r in range(4): + for c in range(4): + assert copy.matrix_world[r][c] == pytest.approx(source.matrix_world[r][c]) + assert tool.ClipBox.get_object_props(copy).is_clip_box is True + + def test_duplicates_specified_index(self): + bpy.ops.bim.add_clip_box() + first = tool.ClipBox.get_active_clip_box() + bpy.ops.bim.add_clip_box() + # Active is now index 1; duplicate index 0 explicitly. + bpy.ops.bim.duplicate_clip_box(index=0) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 3 + copy = tool.ClipBox.get_active_clip_box() + for r in range(4): + for c in range(4): + assert copy.matrix_world[r][c] == pytest.approx(first.matrix_world[r][c]) + + def test_no_active_box_cancels(self): + result = bpy.ops.bim.duplicate_clip_box() + assert result == {"CANCELLED"} + + def test_out_of_range_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.duplicate_clip_box(index=99) + assert result == {"CANCELLED"} + + def test_duplicate_arms_clipping(self): + bpy.ops.bim.add_clip_box() # arms + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # user disables + bpy.ops.bim.duplicate_clip_box() # re-arms + assert scene_props.enabled is True + + +class TestCollectionSync(NewFile): + def test_sync_adopts_orphan_clip_box_empty(self): + # Simulates Bonsai's Shift+D duplicate: an empty with is_clip_box=True + # exists in the BBIM_ClipBoxes collection but no scene-list entry + # points at it. The sync must adopt it as a first-class clip box. + bpy.ops.bim.add_clip_box() + source = tool.ClipBox.get_active_clip_box() + + orphan = bpy.data.objects.new(source.name, None) + orphan.empty_display_type = "CUBE" + orphan.empty_display_size = 1.0 + orphan.matrix_world = source.matrix_world.copy() + tool.ClipBox.get_object_props(orphan).is_clip_box = True + collection = bpy.data.collections.get("BBIM_ClipBoxes") + collection.objects.link(orphan) + + tool.ClipBox._sync_collection_to_list(bpy.context.scene) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 2 + assert scene_props.clip_boxes[-1].obj is orphan + assert scene_props.active_clip_box_index == 1 + + def test_sync_skips_unflagged_empties(self): + bpy.ops.bim.add_clip_box() + collection = bpy.data.collections.get("BBIM_ClipBoxes") + decoy = bpy.data.objects.new("Decoy", None) + collection.objects.link(decoy) + + tool.ClipBox._sync_collection_to_list(bpy.context.scene) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + + +class TestEnabledIsSceneLevel(NewFile): + def test_default_is_false(self): + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is False + + def test_add_arms_clipping(self): + # Adding any clip box flips enabled True so the user + # immediately sees the cut and discovers the panel toggle + # by association. + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is False + bpy.ops.bim.add_clip_box() + assert scene_props.enabled is True + + def test_subsequent_adds_re_arm_after_user_disables(self): + bpy.ops.bim.add_clip_box() # arms + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # user disables + bpy.ops.bim.add_clip_box() # second add re-arms + assert scene_props.enabled is True + + def test_selecting_clip_box_does_not_arm(self): + # Per design, selecting a clip box empty must NOT toggle the + # scene-level enabled — activation is panel-only. Otherwise a + # casual click in the outliner would silently hide geometry + # with no obvious unarm path for a user who hasn't found the + # panel yet. + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # disable after first-add auto-arm + + host = tool.ClipBox.get_active_clip_box() + bpy.context.view_layer.objects.active = host + tool.ClipBox.on_depsgraph_update(bpy.context.scene, None) + assert scene_props.enabled is False + + def test_toggle_operator_flips_scene_enabled(self): + bpy.ops.bim.add_clip_box() # first-add arms it + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is True + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is False + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is True + + +class TestSpawnScale(NewFile): + def test_default_scale_is_ten(self): + # Default spawn scale is 10 (=20m cube) to cover a typical + # storey, not the meaningless 1m unit cube. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert tuple(host.scale) == pytest.approx((10.0, 10.0, 10.0)) + + +class TestCapRebuildDebounce(NewFile): + """The depsgraph handler debounces cap rebuilds so a burst of + updates (e.g. an external-addon gizmo drag) collapses to one + rebuild ~250 ms after the storm subsides. Bonsai's own transform + modals get a fast path: an immediate rebuild on the True→False + transition of ``is_transform_modal_active``. + """ + + def setup_method(self): + bpy.ops.bim.add_clip_box() + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_modal_state = False + tool.ClipBox._last_seen_object_matrices.clear() + + def teardown_method(self): + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_modal_state = False + tool.ClipBox._last_seen_object_matrices.clear() + + def test_modal_end_triggers_immediate_rebuild(self): + # Prime "previous tick had a modal active" then run a tick with + # no modal → fast path fires rebuild_cap_cache synchronously, + # bypassing the timer. Targets _handle_cap_tick directly to + # bypass the screen guard that aborts in headless test runs. + tool.ClipBox._last_modal_state = True + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache") as mock_rebuild, + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + mock_rebuild.assert_called_once() + mock_schedule.assert_not_called() + + def test_burst_collapses_to_one_pending_timer(self): + # 5 ticks with no modal active → schedule called 5 times; each + # call cancels the previous pending timer and registers a fresh + # one, so exactly one timer is pending at the end. + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache"), + ): + for _ in range(5): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + pending = tool.ClipBox._pending_cap_rebuild + assert pending is not None + assert bpy.app.timers.is_registered(pending) + tool.ClipBox._cancel_pending_cap_rebuild() + + def test_pending_rebuild_hides_caps(self): + # While a debounce is in flight, on_post_view_caps must not + # draw — the cached batches reflect an earlier frame and would + # look stale against the geometry being mutated. + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = True + scene_props.show_caps = True + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache"), + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + assert tool.ClipBox._pending_cap_rebuild is not None + + # Populate cap_cache to non-empty so the first-line gate + # "if not cls._cap_cache: return" doesn't fire — the contract + # we're pinning is the pending-rebuild gate specifically. + tool.ClipBox._cap_cache["sentinel"] = ((), None) + try: + # If pending-rebuild gate works, on_post_view_caps exits + # before importing gpu / building a shader. Patch + # gpu.shader.from_builtin to fail loudly if drawing happens. + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch("gpu.shader.from_builtin", side_effect=AssertionError("should be hidden")), + ): + tool.ClipBox.on_post_view_caps() + finally: + tool.ClipBox._cap_cache.pop("sentinel", None) + tool.ClipBox._cancel_pending_cap_rebuild() + + def test_cancel_pending_drops_timer(self): + with patch.object(tool.Blender, "is_transform_modal_active", return_value=False): + tool.ClipBox._schedule_cap_rebuild() + pending = tool.ClipBox._pending_cap_rebuild + assert pending is not None and bpy.app.timers.is_registered(pending) + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + assert not bpy.app.timers.is_registered(pending) + + def test_unregister_handler_cancels_pending(self): + # The module's unregister() must drop any pending rebuild so a + # timer can't fire against a freed addon. Exercise the helper + # directly — full addon unregister would tear down too much for + # a unit test. + with patch.object(tool.Blender, "is_transform_modal_active", return_value=False): + tool.ClipBox._schedule_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is not None + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + + def test_selection_only_tick_does_not_schedule(self): + # Selecting an Object raises is_updated_transform=True on the + # Object itself even though no actual matrix delta occurred + # (Blender quirk). The matrix-hash baseline must filter that + # out so the cache and hide-while-pending gate don't flash on + # every click. Also covers the Scene/ViewLayer noise. + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + + # Prime the baseline so cube's current matrix hash is "seen". + tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world) + + class _SceneUpdate: + id = bpy.context.scene + is_updated_geometry = False + is_updated_transform = True + + class _CubeSelectionUpdate: + id = cube + is_updated_geometry = False + is_updated_transform = True # quirk: matrix unchanged + + class _FakeDepsgraph: + updates = (_SceneUpdate(), _CubeSelectionUpdate()) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_not_called() + assert tool.ClipBox._pending_cap_rebuild is None + + def test_real_transform_tick_does_schedule(self): + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + # Baseline hash, then mutate the matrix so the filter sees a + # true delta on the next tick. + tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world) + cube.matrix_world = cube.matrix_world @ Matrix.Translation((1.0, 0, 0)) + + class _TransformUpdate: + id = cube + is_updated_geometry = False + is_updated_transform = True + + class _FakeDepsgraph: + updates = (_TransformUpdate(),) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_called_once() + + def test_geometry_update_tick_does_schedule(self): + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + + class _GeometryUpdate: + id = cube + is_updated_geometry = True + is_updated_transform = False + + class _FakeDepsgraph: + updates = (_GeometryUpdate(),) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_called_once() + + def test_edit_mode_skips_scheduling(self): + # In any EDIT_* mode the depsgraph fires per vert/edge nudge; + # the cap view isn't the focus and would flash off on every + # tick. The entry-point gate must short-circuit before the + # debounce scheduler runs. + with ( + patch.object(tool.Blender, "is_in_edit_mode", return_value=True), + patch.object(tool.ClipBox, "_handle_cap_tick") as mock_handle, + ): + tool.ClipBox.on_depsgraph_update_caps(bpy.context.scene, None) + mock_handle.assert_not_called() diff --git a/src/bonsai/test/bim/module/drawing/test_gizmos.py b/src/bonsai/test/bim/module/drawing/test_gizmos.py index cc781cd118..18f640549b 100644 --- a/src/bonsai/test/bim/module/drawing/test_gizmos.py +++ b/src/bonsai/test/bim/module/drawing/test_gizmos.py @@ -24,7 +24,11 @@ from types import SimpleNamespace import bpy import pytest -from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig +from bonsai.bim.module.drawing.gizmos import ( + BaseParametricGizmoGroup, + BaseSchematicGizmoGroup, + DimensionGizmoConfig, +) pytestmark = pytest.mark.drawing @@ -52,3 +56,19 @@ def test_text_formatter_receives_props_and_value(): config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter) props = SimpleNamespace(label="L") assert config.text_formatter(props, 3.14) == "L=3.14" + + +def test_parametric_base_enables_dimension_snap_by_default(): + """In-place parametric gizmos align to real-world geometry, so dragging + must respect the global snap toggle (Ctrl-flip during drag) — same + contract every door / window / wall / stair / roof / mep dimension + has shipped with.""" + assert BaseParametricGizmoGroup.snap_enabled_on_dimensions is True + + +def test_schematic_base_disables_dimension_snap(): + """Schematic dimensions float in viewport space; snapping the dragged + tip to scene vertices would produce spurious value jumps as the + mouse crosses unrelated geometry. The opt-out lives on the base so + every schematic subclass inherits it without per-class wiring.""" + assert BaseSchematicGizmoGroup.snap_enabled_on_dimensions is False diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py new file mode 100644 index 0000000000..da668e604a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -0,0 +1,457 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour tests for the unified ``bim.disconnect_elements`` operator and +``tool.Connection.find_rels`` registry. + +Pin the dispatch contract: rels are found in either orientation; the kind +label drives cleanup (``path`` recreates both walls + resyncs drafts; +``element-top`` runs ``regenerate_wall_to_underside``); missing endpoints +report ERROR rather than crashing.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: int = 0): + rel = Mock() + rel.is_a = lambda c: c == klass + rel.RelatingElement = relating + rel.RelatedElement = related + rel.Description = description + rel.id = lambda: rel_id + return rel + + +def _elem(*, connected_to=(), connected_from=()): + e = Mock() + e.ConnectedTo = list(connected_to) + e.ConnectedFrom = list(connected_from) + e.GlobalId = "GUID" + return e + + +# --------------------------------------------------------------------------- +# tool.Connection.find_rels — registry behaviour +# --------------------------------------------------------------------------- + + +def test_find_rels_returns_path_rel_in_either_orientation(): + """The same wall pair can carry path rels authored with either orientation; + find_rels must catch both.""" + elem_a = _elem() + elem_b = _elem() + rel_ab = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1) + rel_ba = _rel("IfcRelConnectsPathElements", relating=elem_b, rel_id=2) + elem_a.ConnectedTo = [rel_ab] + elem_a.ConnectedFrom = [rel_ba] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert {r.id() for r, _ in rels} == {1, 2} + assert all(k == "path" for _, k in rels) + + +def test_find_rels_classifies_top_element_rel_specifically(): + """IfcRelConnectsElements with Description=='TOP' is the rel kind + extend_walls_to_underside creates. Tag it ``element-top`` so the + operator can dispatch the regenerate-wall-to-underside cleanup.""" + wall = _elem() + slab = _elem() + rel = _rel("IfcRelConnectsElements", relating=slab, description="TOP", rel_id=1) + wall.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(wall, slab) + + assert rels == [(rel, "element-top")] + + +def test_find_rels_classifies_non_top_element_rel_generically(): + """Other IfcRelConnectsElements descriptions don't get the TOP-specific + cleanup. Tag as plain ``element`` so the operator just removes the rel.""" + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsElements", relating=elem_b, description="ATTACHMENT", rel_id=1) + elem_a.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert rels == [(rel, "element")] + + +def test_find_rels_returns_empty_when_disconnected(): + elem_a = _elem() + elem_b = _elem() + assert tool.Connection.find_rels(elem_a, elem_b) == [] + + +def test_find_rels_dedups_by_id(): + """A rel that surfaces on both ConnectedTo and ConnectedFrom (in + pathological IFC files) should not be returned twice.""" + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsPathElements", related=elem_b, relating=elem_b, rel_id=1) + elem_a.ConnectedTo = [rel] + elem_a.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert len(rels) == 1 + + +# --------------------------------------------------------------------------- +# tool.Connection.find_rels_for_element — single-element entry point +# --------------------------------------------------------------------------- + + +def test_find_rels_for_element_returns_kind_and_partner_per_rel(): + """Cascade-on-delete needs every rel touching one element plus the partner + element on the other side of each rel — that's the cleanup target.""" + elem = _elem() + partner_a = _elem() + partner_b = _elem() + rel_path = _rel("IfcRelConnectsPathElements", related=partner_a, rel_id=1) + rel_top = _rel("IfcRelConnectsElements", relating=partner_b, description="TOP", rel_id=2) + elem.ConnectedTo = [rel_path] + elem.ConnectedFrom = [rel_top] + + result = tool.Connection.find_rels_for_element(elem) + + assert (rel_path, "path", partner_a) in result + assert (rel_top, "element-top", partner_b) in result + assert len(result) == 2 + + +def test_find_rels_for_element_dedups_by_rel_id(): + elem = _elem() + partner = _elem() + rel = _rel("IfcRelConnectsPathElements", related=partner, relating=partner, rel_id=1) + elem.ConnectedTo = [rel] + elem.ConnectedFrom = [rel] + + result = tool.Connection.find_rels_for_element(elem) + + assert len(result) == 1 + + +def test_find_rels_for_element_skips_rels_without_partner(): + """Defensive: a malformed rel missing the opposite-side attribute should not + crash — record nothing for it rather than emit a (rel, kind, None) triple + that would later trip a None-deref in the dispatch.""" + elem = _elem() + bad = _rel("IfcRelConnectsPathElements", related=None, rel_id=1) + elem.ConnectedTo = [bad] + + assert tool.Connection.find_rels_for_element(elem) == [] + + +# --------------------------------------------------------------------------- +# tool.Connection.find_rel — first-match convenience +# --------------------------------------------------------------------------- + + +def test_find_rel_returns_first_match_or_none_none(): + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1) + elem_a.ConnectedTo = [rel] + + assert tool.Connection.find_rel(elem_a, elem_b) == (rel, "path") + assert tool.Connection.find_rel(elem_a, _elem()) == (None, None) + + +# --------------------------------------------------------------------------- +# tool.Connection.orient_element_top — wall / slab orientation recovery +# --------------------------------------------------------------------------- + + +def test_orient_element_top_returns_wall_then_slab(): + """The TOP rel stores slab as relating + wall as related; orient_element_top + figures out which input is which regardless of argument order.""" + wall = _elem() + slab = _elem() + rel = _rel("IfcRelConnectsElements", relating=slab, related=wall, description="TOP") + + assert tool.Connection.orient_element_top(rel, wall, slab) == (wall, slab) + assert tool.Connection.orient_element_top(rel, slab, wall) == (wall, slab) + + +# --------------------------------------------------------------------------- +# bim.disconnect_elements — dispatch + cleanup +# --------------------------------------------------------------------------- + + +def _make_op(*, a_guid="A", b_guid="B"): + op = Mock() + op.element_a_guid = a_guid + op.element_b_guid = b_guid + op.report = Mock() + return op + + +def test_disconnect_dispatches_one_call_per_rel(): + """Operator forwards every rel returned by find_rels to disconnect_rel, + in order — the operator is a thin wrapper; per-kind cleanup logic lives + in core.connection.disconnect_rel and is tested separately.""" + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + rel1 = Mock() + rel2 = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", + return_value=[(rel1, "path"), (rel2, "element-top")], + ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock() + ), patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): + DisconnectElements._perform(op, context=MagicMock()) + + assert dispatch.call_count == 2 + # Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation + # — orient_element_top inside disconnect_rel recovers the wall/slab roles. + for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]): + kw = call.kwargs + assert kw["rel"] is expected_rel + assert kw["kind"] == expected_kind + assert kw["elem"] is elem_a + assert kw["partner"] is elem_b + op.report.assert_not_called() + + +def test_disconnect_resyncs_path_objs_once_for_path_kind(): + """For path rels the operator collects both endpoint objects and resyncs + drafts once at the end — a Blender-side concern that doesn't belong in + the core dispatch.""" + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + obj_a = Mock() + obj_b = Mock() + rel = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")] + ), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", + side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e], + ), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ), patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ) as resync, patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): + DisconnectElements._perform(op, context=MagicMock()) + + resync.assert_called_once_with([obj_a, obj_b]) + + +def test_disconnect_skips_resync_for_non_path_kind(): + """element-top / element kinds don't need wall-draft resync — that's a + path-specific concern (DumbWallJoiner geometry refresh).""" + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + rel = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] + ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ), patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ) as resync, patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): + DisconnectElements._perform(op, context=MagicMock()) + + resync.assert_not_called() + + +def test_disconnect_gizmo_direction_symmetry(): + """The wall-selected gizmo dispatches with element_a=wall, element_b=slab. + The slab-selected gizmo dispatches with element_a=slab, element_b=wall. + Both routes hit disconnect_rel with the same (rel, kind) pair — orientation + recovery happens inside the dispatch, not at the operator layer.""" + from bonsai.bim.module.model.wall import DisconnectElements + + wall = Mock(name="wall") + slab = Mock(name="slab") + rel = Mock() + + ifc_file = MagicMock() + op = _make_op() + + def _run_with_guids(a, b): + ifc_file.by_guid.side_effect = lambda g: {a: wall if a == "WALL" else slab, b: slab if b == "SLAB" else wall}[g] + op.element_a_guid = a + op.element_b_guid = b + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] + ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ) as dispatch, patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): + DisconnectElements._perform(op, context=MagicMock()) + return dispatch.call_args.kwargs + + wall_first = _run_with_guids("WALL", "SLAB") + slab_first = _run_with_guids("SLAB", "WALL") + + # disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap + # by argument order but orient_element_top inside disconnect_rel resolves + # the wall/slab roles symmetrically. + assert wall_first["rel"] is rel and slab_first["rel"] is rel + assert wall_first["kind"] == slab_first["kind"] == "element-top" + assert {wall_first["elem"], wall_first["partner"]} == {wall, slab} + assert {slab_first["elem"], slab_first["partner"]} == {wall, slab} + + +def test_disconnect_reports_on_unknown_guids(): + from bonsai.bim.module.model.wall import DisconnectElements + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = RuntimeError("missing") + op = _make_op(a_guid="MISSING_A", b_guid="MISSING_B") + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels" + ) as find: + DisconnectElements._perform(op, context=MagicMock()) + + find.assert_not_called() + op.report.assert_called_once() + args, _ = op.report.call_args + assert args[0] == {"ERROR"} + + +def test_disconnect_reports_when_no_rel_found(): + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[] + ): + DisconnectElements._perform(op, context=MagicMock()) + + op.report.assert_called_once() + + +def test_disconnect_operator_is_registered(): + from bonsai.bim.module import model + + assert any( + getattr(cls, "bl_idname", None) == "bim.disconnect_elements" for cls in model.classes + ), "DisconnectElements is not in the model classes tuple" + + +def test_disconnect_refuses_path_kind_when_either_side_is_fillet(): + """The fillet corner's join with its source walls defines its identity + — unjoining there would tear down the chord axis reference. The + operator reports an INFO directing the user to delete the corner + wall and skips the dispatch entirely.""" + from bonsai.bim.module.model.wall import DisconnectElements + + fillet = Mock(name="fillet_corner") + wall = Mock(name="source_wall") + rel = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": wall}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")] + ), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", + side_effect=lambda e: e is fillet, + ), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ) as dispatch: + DisconnectElements._perform(op, context=MagicMock()) + + dispatch.assert_not_called() + op.report.assert_called_once() + args, _ = op.report.call_args + assert args[0] == {"INFO"} + + +def test_disconnect_allows_slab_kind_even_when_wall_is_fillet(): + """The fillet ↔ slab underside clip is a different relationship from + the fillet ↔ source-wall path join. Slab disconnect must remain + available while the corner is in preview.""" + from bonsai.bim.module.model.wall import DisconnectElements + + fillet = Mock(name="fillet_corner") + slab = Mock(name="slab") + rel = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": slab}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] + ), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", + side_effect=lambda e: e is fillet, + ), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock() + ), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ) as dispatch, patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ): + DisconnectElements._perform(op, context=MagicMock()) + + dispatch.assert_called_once() diff --git a/src/bonsai/test/bim/module/model/test_merge_wall_convention.py b/src/bonsai/test/bim/module/model/test_merge_wall_convention.py new file mode 100644 index 0000000000..6447b8541b --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_merge_wall_convention.py @@ -0,0 +1,87 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins the active-is-survivor merge convention. + +``bim.merge_wall`` must consume the non-active selection into the active +one — matching Blender's ``OBJECT_OT_join`` / ``MESH_OT_merge`` "at +last" convention. Users following Ctrl+J muscle-memory click the +surviving wall last; the operator must align with that expectation.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +pytestmark = pytest.mark.wall + + +def _run_perform(active, other): + """Invoke ``MergeWall._perform`` as an unbound function with the + two wall stubs in the selection, patching the heavy IFC / Blender + side effects. Returns the ``(merger_arg_1, merger_arg_2)`` actually + passed to ``DumbWallJoiner.merge``.""" + from bonsai.bim.module.model.wall import MergeWall + + context = SimpleNamespace(active_object=active) + captured_call = {} + + def _capture_merge(self, a, b): + captured_call["wall1"] = a + captured_call["wall2"] = b + + fake_self = SimpleNamespace() + + with ( + patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")), + patch("bonsai.bim.module.model.wall.tool.Model.get_selected_mesh_objects", return_value=[active, other]), + patch("bonsai.bim.module.model.wall.DumbWallJoiner.__init__", return_value=None), + patch("bonsai.bim.module.model.wall.DumbWallJoiner.merge", new=_capture_merge), + patch("bonsai.bim.module.model.wall._maybe_resync_wall_props_from_ifc"), + patch("bonsai.bim.module.model.wall._regenerate_walls") as regen_walls, + ): + result = MergeWall._perform(fake_self, context) + + return captured_call, regen_walls, result + + +def test_active_wall_is_passed_as_survivor_to_merge(): + """The first argument to ``DumbWallJoiner.merge`` is the survivor; + the active object must occupy that slot so the wall the user clicked + last absorbs the other.""" + active = SimpleNamespace(name="active") + other = SimpleNamespace(name="other") + + captured, _regen, _ = _run_perform(active, other) + + assert captured["wall1"] is active + assert captured["wall2"] is other + + +def test_post_merge_resync_targets_active_not_consumed(): + """After the merge ``_regenerate_walls`` rebuilds the survivor's + body. Targeting the consumed wall would crash on a freed ``bpy_struct``; + the survivor (active) is the only valid target.""" + active = SimpleNamespace(name="active") + other = SimpleNamespace(name="other") + + _, regen_walls, _ = _run_perform(active, other) + + regen_walls.assert_called_once_with([active]) diff --git a/src/bonsai/test/bim/module/model/test_railing_lifecycle.py b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py new file mode 100644 index 0000000000..0aad3534b7 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py @@ -0,0 +1,300 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle +behaviour railing inherits from ``PathPreservingEditMixin``. + +The parent short-circuit (skip the IFC commit / viewport rebuild when the +draft is identical to the stored pset) lives in +``PathPreservingEditMixin``; the tests below verify railing's subclass +honours that contract by inheritance, then pin the railing-specific +viewport-restore dispatch: + +- Finish / Cancel no-op short-circuit: inherited from the parent — verified + here because railing was the original consumer that motivated the + optimisation. +- ``_RailingEditMixin._restore_viewport_after_cancel`` dispatch: WALL_MOUNTED_HANDRAIL + reloads the high-poly Body representation via ``switch_representation``; + FRAMELESS_PANEL rebuilds the bmesh preview via + ``update_railing_modifier_bmesh``. This is the per-type branch that used + to live in ``_cancel_one`` and now lives in the viewport-restore hook the + parent's ``_cancel_one`` calls. +""" + +from unittest import mock + +import pytest + +from test.bim.conftest import _FakePropsBase +from test.bim.conftest import make_lifecycle_obj as _make_obj + +pytestmark = pytest.mark.model + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakeRailingProps(_FakePropsBase): + """Stand-in for ``BIMRailingProperties`` — adds ``railing_type`` on top of + the shared parametric-edit contract. Starts in ``is_editing=True`` because + the railing-specific overrides under test only fire on Finish / Cancel, + not on Enable.""" + + def __init__(self, railing_type: str = "WALL_MOUNTED_HANDRAIL", general: dict | None = None): + super().__init__(general=general if general is not None else {"railing_type": railing_type, "height": 1.0}) + self.railing_type = railing_type + self.is_editing = True + + +@pytest.fixture +def patched_railing(): + """Patch the railing module's external references for unit testing. + + ``_RailingEditMixin`` and the parent lifecycle reach for + ``tool.Model.get_modeling_bbim_pset_data``, ``tool.Ifc.get_entity``, + ``ifcopenshell.util.representation.get_representation``, + ``bonsai.core.geometry.switch_representation``, and the module-level + ``update_railing_modifier_bmesh`` — each looked up through the railing + module's own bindings, so we patch them there. + + ``parametric_lifecycle.tool`` is patched separately so the parent's + ``_resolve`` and ``_cancel_one`` can read ``tool.Model.get_modeling_bbim_pset_data`` + without falling through to the real Blender bindings. + + Uses ``mock.patch.object`` with a direct module reference rather than + the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")`` + needs ``pkgutil.resolve_name`` to traverse ``bonsai → bim → module → …``, + which fails at the ``bonsai.bim`` step until that subpackage has been + imported elsewhere. The direct-object form sidesteps the resolution. + + Returns a dict for tests to seed return values and assert call sites. + """ + from bonsai.bim import parametric_lifecycle + from bonsai.bim.module.model import railing + + with ( + mock.patch.object(railing, "tool") as mock_tool, + mock.patch.object(railing, "ifcopenshell") as mock_ifc, + mock.patch.object(railing, "bonsai") as mock_bonsai, + mock.patch.object(railing, "update_railing_modifier_bmesh") as mock_update_bmesh, + mock.patch.object(parametric_lifecycle, "tool") as mock_pl_tool, + ): + # _resolve will be overridden on the test subclass below so the + # parametric_lifecycle.tool patch isn't needed for that path, but the + # parent's _cancel_one / _finish_one still call + # tool.Model.get_modeling_bbim_pset_data and would otherwise miss. + mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity") + yield { + "tool": mock_tool, + "ifcopenshell": mock_ifc, + "bonsai": mock_bonsai, + "update_bmesh": mock_update_bmesh, + "pl_tool": mock_pl_tool, + } + + +def _railing_test_subclass(props): + """Build a ``_RailingEditMixin`` subclass that bypasses ``_resolve``. + + The base ``_resolve`` reads ``tool.Ifc.get_entity`` from + ``parametric_lifecycle.tool`` (a separate import from the railing + module's ``tool``). Overriding it here keeps the test patches local + to the railing module and the hook closures local to the test.""" + from bonsai.bim.module.model.railing import _RailingEditMixin + + test_element = mock.Mock(name="ifc_element") + + class _TestRailingMixin(_RailingEditMixin): + pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset") + ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data") + bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel") + + @classmethod + def _resolve(cls, obj): + return test_element, props + + @classmethod + def _update_pset(cls, element, data): + cls.pset_updates(element, data) + + @classmethod + def _update_modifier_ifc_data(cls, obj, context): + cls.ifc_data_updates(obj, context) + + @classmethod + def _restore_viewport_after_cancel(cls, obj, context): + cls.bmesh_updates(obj, context) + + # The base _post_load_data JSON-serialises path_data; bypass that + # here so the round-trip stays a plain dict and tests can compare + # by reference / equality without re-parsing. + @classmethod + def _post_load_data(cls, data): + return dict(data) + + return _TestRailingMixin, test_element + + +# --------------------------------------------------------------------------- +# _RailingEditMixin._finish_one +# --------------------------------------------------------------------------- + + +def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing): + """Enable → Finish without any property edit must NOT write to IFC. + + Without this, every "open Edit, click Validate immediately" cycle + would create a fresh ``IfcShapeRepresentation``, pollute the file's + representation list, and burn an undo entry — the user-visible + regression that motivated the short-circuit. + + Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps + the coverage as the original consumer of the contract. + """ + stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} + props = _FakeRailingProps(general=dict(stored)) + obj = _make_obj(props) + patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, + } + + cls, _element = _railing_test_subclass(props) + cls._finish_one(obj, mock.Mock(name="context")) + + assert props.is_editing is False, "is_editing must still flip even on no-op" + cls.pset_updates.assert_not_called() + cls.ifc_data_updates.assert_not_called() + + +def test_finish_one_writes_when_draft_differs(patched_railing): + """The complement of the short-circuit: a real property change must + flow through to ``_update_pset`` + ``_update_modifier_ifc_data``.""" + stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} + # Draft height differs: simulating a user edit. + props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5}) + obj = _make_obj(props) + patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, + } + + cls, element = _railing_test_subclass(props) + cls._finish_one(obj, mock.Mock(name="context")) + + assert props.is_editing is False + cls.pset_updates.assert_called_once() + # The pset must receive the DRAFT data, not the stored data — that's the + # whole point of Finish committing the user's edits. + written = cls.pset_updates.call_args[0][1] + assert written["height"] == 1.5 + cls.ifc_data_updates.assert_called_once_with(obj, mock.ANY) + + +# --------------------------------------------------------------------------- +# _RailingEditMixin._cancel_one +# --------------------------------------------------------------------------- + + +def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing): + """Cancel-without-changes is asymmetrically expensive without this guard: + ``switch_representation`` re-tessellates the IfcSweptDiskSolid and is + visibly slow on a long handrail. When nothing changed, the mesh on + screen is still the committed IFC representation (the preview only + builds on a property change) — skip the reload entirely. + + Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps + the coverage as the original consumer of the contract. + """ + stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} + props = _FakeRailingProps(general=dict(stored)) + obj = _make_obj(props) + patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, + } + + cls, _element = _railing_test_subclass(props) + cls._cancel_one(obj, mock.Mock(name="context")) + + assert props.is_editing is False + patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called() + patched_railing["update_bmesh"].assert_not_called() + cls.bmesh_updates.assert_not_called() + + +# --------------------------------------------------------------------------- +# _RailingEditMixin._restore_viewport_after_cancel — per-type viewport-restore dispatch +# +# The parent's _cancel_one calls cls._restore_viewport_after_cancel whenever +# the draft differs from the stored pset. Railing's override branches on +# railing_type so WALL_MOUNTED_HANDRAIL reloads the high-poly Body +# representation rather than rebuilding the low-poly cylinder-segment preview. +# --------------------------------------------------------------------------- + + +def test_restore_viewport_wall_mounted_handrail_switches_representation(patched_railing): + """WALL_MOUNTED_HANDRAIL restore must call ``switch_representation`` with + the Body representation — the preview is viewport-only (low-poly cylinder) + and would persist visibly without the reload.""" + from bonsai.bim.module.model.railing import _RailingEditMixin + + props = _FakeRailingProps(railing_type="WALL_MOUNTED_HANDRAIL") + obj = _make_obj(props) + patched_railing["tool"].Model.get_railing_props.return_value = props + body_repr = mock.Mock(name="body_representation") + patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr + + _RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context")) + + patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once() + kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs + assert kwargs["obj"] is obj + assert kwargs["representation"] is body_repr + # Must NOT fall through to the FRAMELESS bmesh-rebuild path. + patched_railing["update_bmesh"].assert_not_called() + + +def test_restore_viewport_frameless_panel_calls_module_bmesh_rebuild(patched_railing): + """FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC + swept-disk solid to reload. The restore must delegate to the module-level + ``update_railing_modifier_bmesh`` rebuilder rather than swap representations.""" + from bonsai.bim.module.model.railing import _RailingEditMixin + + props = _FakeRailingProps(railing_type="FRAMELESS_PANEL") + obj = _make_obj(props) + patched_railing["tool"].Model.get_railing_props.return_value = props + ctx = mock.Mock(name="context") + + _RailingEditMixin._restore_viewport_after_cancel(obj, ctx) + + patched_railing["update_bmesh"].assert_called_once_with(ctx) + patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called() + + +# --------------------------------------------------------------------------- +# _get_railing_path_anchor: tests removed. +# +# The schematic-redesign branch replaced ``GizmoRailingEdition`` with +# ``GizmoRailingSchematic``, which anchors via the schematic frame rather +# than the polyline's first vertex. ``_get_railing_path_anchor`` was the +# helper for the old anchor strategy and has been deleted along with the +# old gizmo group. If schematic-mode gains a similar path-derived helper, +# new tests should land here. +# --------------------------------------------------------------------------- diff --git a/src/bonsai/test/bim/module/model/test_railing_schematic.py b/src/bonsai/test/bim/module/model/test_railing_schematic.py new file mode 100644 index 0000000000..7ba12a7a70 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_railing_schematic.py @@ -0,0 +1,270 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import types +from types import SimpleNamespace + +import bmesh +import bpy +import pytest + +from bonsai import tool +from bonsai.bim.module.drawing.gizmos import ( + BaseSchematicGizmoGroup, + DimensionGizmoConfig, +) +from bonsai.bim.module.model.railing import GizmoRailingSchematic + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + """Skip the file when ``bpy`` is mocked or absent. + + Without this guard, mis-routed test runs (e.g. ``pytest test/bim/...`` + invoked outside Blender) crash at module-collection time on the chain of + ``bonsai.tool`` imports below, instead of producing a clean ``skipped``. + """ + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +# ── Class shape ────────────────────────────────────────────────────────────── + + +def test_railing_schematic_inherits_base(): + """GizmoRailingSchematic plugs into the schematic framework, not the + in-place dimension framework. If a future refactor breaks this lineage + the schematic-specific machinery (sliders, draw handler) silently goes + dormant.""" + assert issubclass(GizmoRailingSchematic, BaseSchematicGizmoGroup) + + +def test_railing_schematic_bl_idname_preserved(): + """``OBJECT_GGT_bim_railing_edition`` is the user-facing identifier and + is referenced by keymaps and persistence. Preserve it across the class + rename — see the migration note in the class docstring.""" + assert GizmoRailingSchematic.bl_idname == "OBJECT_GGT_bim_railing_edition" + + +def test_railing_schematic_props_getter_pairing(): + """``gizmo_pref_name = "railing"`` and ``props_getter = tool.Model.get_railing_props`` + are the pairing test_parametric_registry depends on. If either drifts, + the addon-preferences gizmo toggle silently stops controlling this group.""" + assert GizmoRailingSchematic.gizmo_pref_name == "railing" + assert GizmoRailingSchematic.props_getter == tool.Model.get_railing_props + + +def test_railing_schematic_disables_in_place_dimension_props(): + """The schematic owns the value-input surface — no in-place dimensions on the actual geometry.""" + assert GizmoRailingSchematic.dimension_gizmo_props == [] + + +# ── Dimension configuration ───────────────────────────────────────────────── + + +def test_railing_schematic_has_six_dimensions(): + """One dimension per parametric property — three for each railing_type.""" + assert len(GizmoRailingSchematic.schematic_dimension_props) == 6 + + +def test_railing_schematic_dimension_attr_names_complete(): + """The six bound attributes match the parametric properties that + ``update_railing_modifier_bmesh`` reads when regenerating the live preview.""" + attr_names = {c.attr_name for c in GizmoRailingSchematic.schematic_dimension_props} + assert attr_names == { + "height", + "thickness", + "spacing", + "railing_diameter", + "clear_width", + "support_spacing", + } + + +def test_railing_schematic_dimensions_are_dimension_configs(): + """The dimension-line aesthetic depends on ``DimensionGizmoConfig`` (with + arrows + label), not the abstract slider widget.""" + for config in GizmoRailingSchematic.schematic_dimension_props: + assert isinstance(config, DimensionGizmoConfig) + + +def test_railing_schematic_dimensions_have_text_formatters(): + """Each dimension must format the label from the actual property value, + not from the visually-scaled value the gizmo's getter returns. Without a + formatter the label would show the schematic-scaled length, which is + meaningless to the user.""" + for config in GizmoRailingSchematic.schematic_dimension_props: + assert config.text_formatter is not None, f"{config.attr_name} missing text_formatter" + + +@pytest.mark.parametrize( + "attr_name,railing_type,expected", + [ + ("height", "FRAMELESS_PANEL", True), + ("height", "WALL_MOUNTED_HANDRAIL", False), + ("thickness", "FRAMELESS_PANEL", True), + ("spacing", "FRAMELESS_PANEL", True), + ("railing_diameter", "WALL_MOUNTED_HANDRAIL", True), + ("railing_diameter", "FRAMELESS_PANEL", False), + ("clear_width", "WALL_MOUNTED_HANDRAIL", True), + ], +) +def test_railing_schematic_dimension_visibility_gated_by_railing_type(attr_name, railing_type, expected): + """The two railing types are mutually exclusive — height/thickness/spacing + belong to FRAMELESS_PANEL; railing_diameter/clear_width/support_spacing + belong to WALL_MOUNTED_HANDRAIL. The visibility lambdas enforce that.""" + config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == attr_name) + props = SimpleNamespace(railing_type=railing_type, use_manual_supports=False) + assert config.visibility_condition(props) is expected + + +def test_railing_schematic_support_spacing_hidden_for_manual_supports(): + """``support_spacing`` only drives auto-positioned supports — when the + user has switched to manual supports the dimension should disappear.""" + config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == "support_spacing") + auto = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=False) + manual = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=True) + assert config.visibility_condition(auto) is True + assert config.visibility_condition(manual) is False + + +# ── Fixed-length tag rendering ───────────────────────────────────────────── + + +def test_schematic_dim_visible_length_is_constant(): + """Every schematic dimension tag renders at the same width — the bar is a + UI affordance, not a proportional measurement. The constant ratio keeps + tiny (5 mm thickness) and huge (5 m height) values equally clickable; the + real value lives in the dimension label. + + Regression guard: if value-proportional scaling is reintroduced, this + contract breaks silently — small dimensions start collapsing into stacked + arrows again. + """ + cls = GizmoRailingSchematic + ratio = cls.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO + assert ratio > 0 + assert ratio <= 1.0 # bar must fit within the schematic box + + +def test_schematic_no_compute_schematic_scale_override(): + """The constant-length schematic must not reintroduce scale-based + proportional sizing via a ``_compute_schematic_scale`` override.""" + assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__ + + +# ── Path-edit guard ───────────────────────────────────────────────────────── + + +def test_update_editing_gizmos_override_defined_on_subclass(): + """``GizmoRailingSchematic`` must own the override that hides the pen + icon during path-edit. The parent's version shows the pen whenever + ``is_editing`` is False, which includes path-edit; that would let the + user open two editing modes at once.""" + assert "update_editing_gizmos" in GizmoRailingSchematic.__dict__ + + +# ── Schematic mesh building ───────────────────────────────────────────────── + + +def test_build_schematic_mesh_frameless_panel_returns_bmesh_with_edges(): + """FRAMELESS_PANEL renders as two separated wireframe boxes — 8 corners + per box × 2 = 16 verts; 12 edges per box × 2 = 24 edges. The visible + gap between the two boxes is the "spacing" semantic made literal. + + The mesh proportions are fixed (independent of property values) so the + dimension gizmos can anchor to known feature positions; the property + values are shown through dimension labels, not the mesh size.""" + props = SimpleNamespace( + railing_type="FRAMELESS_PANEL", + height=1.0, + thickness=0.05, + spacing=0.5, + ) + bm = GizmoRailingSchematic.build_schematic_mesh(props) + try: + assert isinstance(bm, bmesh.types.BMesh) + assert len(bm.verts) == 16 + assert len(bm.edges) == 24 + finally: + bm.free() + + +def test_build_schematic_mesh_wall_mounted_handrail_returns_bmesh_with_edges(): + """WALL_MOUNTED_HANDRAIL renders as three visual elements: + + - **Wall outline** — 4 corner verts, 4 edges (rectangle at z=0). + - **Hex tube** — 12 verts (6 per ring × 2 ends), 18 edges + (6 left ring + 6 right ring + 6 axial). + - **L-brackets** at each rail end — 3 verts per bracket (rail centre, + corner, wall attach) × 2 brackets = 6 verts; 2 edges per bracket + (rail→corner, corner→wall) × 2 = 4 edges. + + Total: 22 verts, 26 edges. + """ + props = SimpleNamespace( + railing_type="WALL_MOUNTED_HANDRAIL", + railing_diameter=0.05, + clear_width=0.04, + support_spacing=1.0, + ) + bm = GizmoRailingSchematic.build_schematic_mesh(props) + try: + assert isinstance(bm, bmesh.types.BMesh) + assert len(bm.verts) == 22 + assert len(bm.edges) == 26 + finally: + bm.free() + + +def test_build_schematic_mesh_proportions_independent_of_props(): + """The mesh uses fixed proportions so dimension gizmo anchor points stay + aligned with the geometry — extreme prop ratios don't change the mesh.""" + small = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=0.01, thickness=0.005, spacing=0.05) + large = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=10.0, thickness=0.5, spacing=2.0) + bm_small = GizmoRailingSchematic.build_schematic_mesh(small) + bm_large = GizmoRailingSchematic.build_schematic_mesh(large) + try: + # Same vert count regardless of prop magnitude. + assert len(bm_small.verts) == len(bm_large.verts) + # Same bounding box in each axis (within floating-point noise). + for axis in range(3): + small_coords = [v.co[axis] for v in bm_small.verts] + large_coords = [v.co[axis] for v in bm_large.verts] + assert min(small_coords) == pytest.approx(min(large_coords)) + assert max(small_coords) == pytest.approx(max(large_coords)) + finally: + bm_small.free() + bm_large.free() + + +def test_build_schematic_mesh_panel_top_matches_height_frac(): + """The panel's top edge sits at exactly ``SCHEMATIC_MESH_HEIGHT_FRAC``, + which is also where the ``thickness`` dimension anchors above the box. + If this drifts, the dimension labels float disconnected from the mesh.""" + props = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=1.0, thickness=0.05, spacing=0.3) + bm = GizmoRailingSchematic.build_schematic_mesh(props) + try: + max_y = max(v.co.y for v in bm.verts) + assert max_y == pytest.approx(GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC) + finally: + bm.free() diff --git a/src/bonsai/test/bim/module/model/test_regenerate_wall.py b/src/bonsai/test/bim/module/model/test_regenerate_wall.py new file mode 100644 index 0000000000..68ca4bcdf9 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_regenerate_wall.py @@ -0,0 +1,85 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins the branching contract of ``tool.Model.regenerate_wall``. + +The body rebuild always runs (extrusion + openings); the slab re-clip only +runs when an ``IfcRelConnectsElements(TOP)`` rel survives. A wall without +either feature still completes without crashing.""" + +from unittest.mock import Mock, patch + +import pytest + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +def test_regenerate_wall_rebuilds_body_and_reclips_when_connected(): + """Wall with a TOP connection: body rebuilt first, then re-clipped.""" + element = Mock() + obj = Mock() + + with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object( + tool.Model, "recreate_wall" + ) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=True), patch( + "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside" + ) as regen: + tool.Model.regenerate_wall(obj) + + recreate.assert_called_once_with(element, obj) + regen.assert_called_once() + args, _ = regen.call_args + assert args[3] == [obj] + + +def test_regenerate_wall_skips_reclip_when_no_top_rel(): + """Wall without a TOP connection: body rebuilt; re-clip skipped.""" + element = Mock() + obj = Mock() + + with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object( + tool.Model, "recreate_wall" + ) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=False), patch( + "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside" + ) as regen: + tool.Model.regenerate_wall(obj) + + recreate.assert_called_once_with(element, obj) + regen.assert_not_called() + + +def test_regenerate_wall_noops_when_obj_has_no_ifc_entity(): + """Non-IFC objects (e.g. a freshly created Blender mesh before + `tool.Ifc.run("root.create_entity")` runs) return None from get_entity; + the helper must return without touching the body or any rels.""" + obj = Mock() + + with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=None), patch.object( + tool.Model, "recreate_wall" + ) as recreate, patch.object(tool.Model, "has_underside_connection") as has_top, patch( + "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside" + ) as regen: + tool.Model.regenerate_wall(obj) + + recreate.assert_not_called() + has_top.assert_not_called() + regen.assert_not_called() diff --git a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py index 6c1b367b7c..6e4a7fdca9 100644 --- a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py +++ b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py @@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule): - ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base parametric poll already filters array children. +- ``GizmoSlabEdition`` — same as ``GizmoWallEdition`` (inherits + ``BaseParametricGizmoGroup`` whose poll filters array children). - ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire WHILE its own preview is active; routing it through the topology gate would self-block it. @@ -47,9 +49,11 @@ pytestmark = pytest.mark.model # Wall gizmo groups intentionally outside the rule. Add a new entry only # with the in-code reasoning above. -_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"}) +_ALLOWLIST = frozenset({"GizmoSlabEdition", "GizmoWallEdition", "GizmoWallFilletPreview"}) -_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"}) +_REQUIRED_CALLEES = frozenset( + {"_wall_topology_gizmo_poll_gate", "_slab_connection_gizmo_poll_gate", "any_selected_is_array_child"} +) def _wall_module_source(): diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_gizmos.py index c3b97e9466..4bdd78ed4a 100644 --- a/src/bonsai/test/bim/module/model/test_wall_gizmos.py +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos.py @@ -50,12 +50,15 @@ def _make_context(active, selected): return SimpleNamespace(active_object=active, selected_objects=list(selected)) -def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage): +def _patch_tools( + prefs_on, selected, active_element, other_element, active_usage, other_usage, other_is_path_connectable=None +): """Return a stack of patches that simulate one selection / IFC state for poll(). ``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The - selection set, the IFC entity lookup, and the usage-type lookup are stubbed - so the test only depends on the predicate ordering in poll().""" + selection set, the IFC entity lookup, the usage-type lookup, and the + path-connectable-wall predicate are stubbed so the test only depends on + the predicate ordering in poll().""" prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on)) entity_map = {} @@ -67,12 +70,18 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage usage_map[id(active_element)] = active_usage usage_map[id(other_element)] = other_usage + if other_is_path_connectable is None: + other_is_path_connectable = other_usage == "LAYER2" + def get_entity(obj): return entity_map.get(id(obj)) def get_usage_type(element): return usage_map.get(id(element)) + def is_path_connectable_wall(element): + return element is other_element and other_is_path_connectable + from bonsai import tool return [ @@ -80,6 +89,7 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)), patch.object(tool.Ifc, "get_entity", side_effect=get_entity), patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type), + patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=is_path_connectable_wall), # The array-child filter is pinned by its own test file; stub it here # so these poll tests stay focused on the count / layer-usage gates # and don't have to scaffold the memoization cache key. @@ -87,7 +97,15 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage ] -def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True): +def _run_poll( + prefs_on, + active_is_in_selected, + len_override, + active_usage, + other_usage, + active_has_entity=True, + other_is_path_connectable=None, +): from bonsai.bim.module.model.wall import GizmoWallExtendVertically slab_obj = _Obj("slab") @@ -103,7 +121,15 @@ def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other slab_element = object() if active_has_entity else None wall_element = object() - patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage) + patches = _patch_tools( + prefs_on, + selected, + slab_element, + wall_element, + active_usage, + other_usage, + other_is_path_connectable=other_is_path_connectable, + ) for p in patches: p.start() try: @@ -189,6 +215,23 @@ def test_poll_rejects_when_other_is_not_layer2_wall(): ) +def test_poll_accepts_fillet_corner_wall_partner(): + # Fillet-corner walls carry no LAYER2 usage by spec but the extend-to- + # underside operator handles them just like a parametric LAYER2 wall — + # the gizmo must surface for the slab + fillet-corner selection too. + assert ( + _run_poll( + prefs_on=True, + active_is_in_selected=True, + len_override=None, + active_usage="LAYER3", + other_usage=None, + other_is_path_connectable=True, + ) + is True + ) + + # ---------------------------------------------------------------------------- # _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk # ---------------------------------------------------------------------------- diff --git a/src/bonsai/test/bim/module/model/test_wall_merge_openings.py b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py new file mode 100644 index 0000000000..27f7779357 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py @@ -0,0 +1,276 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins the contract that ``DumbWallJoiner.merge`` re-hosts openings from +the discarded wall to the survivor before the cascade delete tears down +``element2.HasOpenings`` and any filling that references them. + +``edit_object_placement`` preserves the opening's world position when the +two walls have different placements — a ``PlacementRelTo`` swap alone +would shift the opening as the relative offset changes.""" + +from unittest.mock import MagicMock, Mock, patch + +import numpy as np +import pytest + +pytestmark = pytest.mark.wall + + +def _opening_rel(opening_id: int, placement_matrix: np.ndarray): + """Build a stub ``IfcRelVoidsElement`` carrying an opening with a known + placement. ``RelatingBuildingElement`` is settable so the test can + observe the re-host.""" + opening = Mock(name=f"opening_{opening_id}") + opening.id.return_value = opening_id + opening.ObjectPlacement = Mock(name=f"opening_placement_{opening_id}") + rel = Mock(name=f"voids_rel_{opening_id}") + rel.RelatedOpeningElement = opening + rel.RelatingBuildingElement = None + return rel, opening, placement_matrix + + +def _merge_inputs(*, has_openings): + """Stage the minimum wall1 + wall2 + element1 + element2 surface that + ``DumbWallJoiner.merge`` reads. The reference lines and placements are + rigged so the collinearity guard passes and execution reaches the + opening-migration loop.""" + wall1 = Mock(name="wall1") + wall2 = Mock(name="wall2") + element1 = Mock(name="element1") + element2 = Mock(name="element2") + element1.ObjectPlacement = Mock(name="elem1_placement") + element2.ObjectPlacement = Mock(name="elem2_placement") + element1.ConnectedTo = [] + element1.ConnectedFrom = [] + element2.ConnectedTo = [] + element2.ConnectedFrom = [] + element2.HasOpenings = list(has_openings) + return wall1, wall2, element1, element2 + + +def _run_merge(wall1, wall2, element1, element2, opening_matrices, captured_edit_calls): + """Invoke ``DumbWallJoiner().merge`` against the staged inputs with + every heavy IFC / Blender side effect patched out. ``opening_matrices`` + maps an opening id to its captured world matrix; ``captured_edit_calls`` + is appended to whenever ``edit_object_placement`` fires.""" + from bonsai.bim.module.model.wall import DumbWallJoiner + + def fake_get_local_placement(placement): + for rel in element2.HasOpenings: + if rel.RelatedOpeningElement.ObjectPlacement is placement: + return opening_matrices[rel.RelatedOpeningElement.id()] + return np.eye(4) + + def fake_get_entity(obj): + return {wall1: element1, wall2: element2}[obj] + + def fake_edit_object_placement(ifc_file, *, product, matrix, is_si, should_transform_children): + captured_edit_calls.append( + { + "product": product, + "matrix": matrix, + "is_si": is_si, + "should_transform_children": should_transform_children, + } + ) + + p1 = np.array([0.0, 0.0]) + p2 = np.array([5.0, 0.0]) + p3 = np.array([5.0, 0.0]) + p4 = np.array([10.0, 0.0]) + + with ( + patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False), + patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity), + patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line", + side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4), + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement", + side_effect=fake_get_local_placement, + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.edit_object_placement", + side_effect=fake_edit_object_placement, + ), + patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"), + patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object") as delete_ifc_object, + patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"), + ): + DumbWallJoiner().merge(wall1, wall2) + return delete_ifc_object + + +def test_merge_rehosts_each_opening_to_survivor(): + """Every void rel on the discarded wall is rebound to the survivor so + the cascade delete doesn't take them down with element2.""" + matrix_a = np.eye(4) + matrix_a[0, 3] = 1.0 + matrix_b = np.eye(4) + matrix_b[0, 3] = 3.0 + rel_a, opening_a, _ = _opening_rel(opening_id=101, placement_matrix=matrix_a) + rel_b, opening_b, _ = _opening_rel(opening_id=102, placement_matrix=matrix_b) + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel_a, rel_b]) + + _run_merge( + wall1, + wall2, + element1, + element2, + opening_matrices={101: matrix_a, 102: matrix_b}, + captured_edit_calls=[], + ) + + assert rel_a.RelatingBuildingElement is element1 + assert rel_b.RelatingBuildingElement is element1 + + +def test_merge_preserves_opening_world_placement(): + """``edit_object_placement`` re-applies the opening's pre-merge world + matrix so the void doesn't drift when the two walls have different + placements — the regression a ``PlacementRelTo`` swap alone would + fail.""" + matrix = np.eye(4) + matrix[:3, 3] = (2.5, 0.0, 0.0) + rel, opening, _ = _opening_rel(opening_id=42, placement_matrix=matrix) + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel]) + captured: list[dict] = [] + + _run_merge( + wall1, + wall2, + element1, + element2, + opening_matrices={42: matrix}, + captured_edit_calls=captured, + ) + + edit_calls_for_opening = [call for call in captured if call["product"] is opening] + assert len(edit_calls_for_opening) == 1 + np.testing.assert_allclose(edit_calls_for_opening[0]["matrix"], matrix, atol=1e-9) + assert edit_calls_for_opening[0]["should_transform_children"] is False + + +def test_merge_rehosts_before_delete(): + """Order matters: ``delete_ifc_object`` cascades through + ``element2.HasOpenings`` and would destroy the void if it ran before + the re-host. Assert the survivor was rebound before delete fires.""" + matrix = np.eye(4) + rel, opening, _ = _opening_rel(opening_id=7, placement_matrix=matrix) + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel]) + + delete_ifc_object = _run_merge( + wall1, + wall2, + element1, + element2, + opening_matrices={7: matrix}, + captured_edit_calls=[], + ) + + assert rel.RelatingBuildingElement is element1 + delete_ifc_object.assert_called_once_with(wall2) + + +def test_merge_skips_non_path_connection_rels(): + """``ConnectedTo`` / ``ConnectedFrom`` carry both + ``IfcRelConnectsPathElements`` (wall-wall joins) AND + ``IfcRelConnectsElements`` (slab underside clips). Only the path rels + expose ``RelatingConnectionType`` / ``RelatedConnectionType``; + accessing those attributes on an element rel raises ``AttributeError``. + The migration loop must filter on the rel class so a wall with a slab + clip can still be merged.""" + from bonsai.bim.module.model.wall import DumbWallJoiner + + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[]) + + path_rel = Mock(name="path_rel") + path_rel.is_a = lambda c: c == "IfcRelConnectsPathElements" + path_rel.RelatingElement = Mock(name="rel_relating") + path_rel.RelatedElement = Mock(name="rel_related") + path_rel.RelatingConnectionType = "ATSTART" + path_rel.RelatedConnectionType = "ATEND" + + slab_rel = Mock(name="slab_rel") + slab_rel.is_a = lambda c: c == "IfcRelConnectsElements" + slab_rel.Description = "TOP" + # ``RelatedConnectionType`` is what the merge loop reads from + # ``ConnectedFrom``; the real ``IfcRelConnectsElements`` schema has + # no such attribute, so wire the stub to raise like ifcopenshell does. + type(slab_rel).RelatedConnectionType = property( + lambda self: (_ for _ in ()).throw(AttributeError("RelatedConnectionType")) + ) + type(slab_rel).RelatingConnectionType = property( + lambda self: (_ for _ in ()).throw(AttributeError("RelatingConnectionType")) + ) + element2.ConnectedFrom = [slab_rel, path_rel] + + captured_disconnects = [] + captured_connects = [] + + def fake_disconnect_path(*args, **kwargs): + captured_disconnects.append(kwargs) + + def fake_connect_path(*args, **kwargs): + captured_connects.append(kwargs) + + p1 = np.array([0.0, 0.0]) + p2 = np.array([5.0, 0.0]) + p3 = np.array([5.0, 0.0]) + p4 = np.array([10.0, 0.0]) + + def fake_get_entity(obj): + return {wall1: element1, wall2: element2}[obj] + + with ( + patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False), + patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity), + patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line", + side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4), + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement", + return_value=np.eye(4), + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_path", + side_effect=fake_disconnect_path, + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.connect_path", + side_effect=fake_connect_path, + ), + patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"), + patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object"), + patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"), + ): + # The bug pre-fix: the slab rel's ``RelatedConnectionType`` access + # raised AttributeError and crashed merge. With the filter, this + # call must complete cleanly. + DumbWallJoiner().merge(wall1, wall2) + + assert len(captured_disconnects) == 1 + assert len(captured_connects) == 1 + assert captured_disconnects[0]["connection_type"] == "ATEND" diff --git a/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py new file mode 100644 index 0000000000..68a95782fd --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py @@ -0,0 +1,64 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins the contract that the three dimension-mutating wall operators — +``bim.change_extrusion_depth``, ``bim.change_extrusion_x_angle``, +``bim.change_layer_length`` — re-prime ``BIMWallProperties`` from the +post-mutation IFC at the end of ``_execute``. + +Without the resync, ``props.height`` / ``props.length`` / ``props.x_angle`` +stay at their pre-mutation values; gizmo icons that position from +``props.height`` then sit at the old elevation even though the wall mesh +shows the new one.""" + +import inspect + +import pytest + +pytestmark = pytest.mark.wall + + +def _execute_source(operator_cls): + return inspect.getsource(operator_cls._execute) + + +def test_change_extrusion_depth_resyncs_wall_props(): + """Height mutation must re-prime ``BIMWallProperties.height`` so + gizmo icons positioned from ``props.height`` track the post-mutation + wall top in the same redraw.""" + from bonsai.bim.module.model.wall import ChangeExtrusionDepth + + assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth) + + +def test_change_extrusion_x_angle_resyncs_wall_props(): + """Slope mutation must re-prime ``BIMWallProperties.x_angle`` so + slope-driven gizmo positions track the new angle.""" + from bonsai.bim.module.model.wall import ChangeExtrusionXAngle + + assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle) + + +def test_change_layer_length_resyncs_wall_props(): + """Length mutation must re-prime ``BIMWallProperties.length`` so + horizontal gizmo X positions track the new axis extent.""" + from bonsai.bim.module.model.wall import ChangeLayerLength + + assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength) diff --git a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py new file mode 100644 index 0000000000..5fff484943 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py @@ -0,0 +1,213 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour tests for the wall-slab connection helpers on tool.Wall. + +Pins the rel-shape contract (IfcRelConnectsElements with Description=="TOP") +the underside-extension feature creates, and the icon placement contract the +new wall-slab connection gizmo group reads.""" + +from unittest.mock import Mock, patch + +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +def _rel(klass: str = "IfcRelConnectsElements", description: str = "TOP", relating=None, related=None): + rel = Mock() + rel.is_a = lambda c: c == klass + rel.Description = description + rel.RelatingElement = relating + rel.RelatedElement = related + return rel + + +def _wall_with_rels(*rels) -> Mock: + wall = Mock() + wall.ConnectedFrom = list(rels) + return wall + + +def _slab_with_rels(*rels) -> Mock: + slab = Mock() + slab.ConnectedTo = list(rels) + return slab + + +# --------------------------------------------------------------------------- +# iter_wall_slab_connections — yields (slab, rel) for TOP rels +# --------------------------------------------------------------------------- + + +def test_iter_wall_slab_connections_yields_top_rels(): + slab_a = Mock(name="slab_a") + slab_b = Mock(name="slab_b") + wall = _wall_with_rels( + _rel(relating=slab_a), + _rel(relating=slab_b), + ) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert result == [(slab_a, wall.ConnectedFrom[0]), (slab_b, wall.ConnectedFrom[1])] + + +def test_iter_wall_slab_connections_skips_non_top_description(): + """Only TOP-described rels count; BOTTOM / SIDE / arbitrary strings are + skipped so other RelConnectsElements semantics aren't confused with the + underside-extension contract.""" + slab = Mock() + wall = _wall_with_rels( + _rel(description="BOTTOM", relating=slab), + _rel(description="TOP", relating=slab), + ) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert len(result) == 1 + assert result[0][0] is slab + + +def test_iter_wall_slab_connections_skips_non_connectselements_rels(): + """Path-connections to other walls show up on ConnectedFrom too — the + helper must filter on rel class, not just presence.""" + slab = Mock() + wall = _wall_with_rels( + _rel(klass="IfcRelConnectsPathElements", relating=slab), + _rel(klass="IfcRelConnectsElements", relating=slab), + ) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert len(result) == 1 + + +def test_iter_wall_slab_connections_handles_none_relating(): + """A malformed rel with RelatingElement=None is skipped rather than + raising — defensive against partially-loaded IFC files.""" + wall = _wall_with_rels(_rel(relating=None)) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert result == [] + + +def test_iter_wall_slab_connections_empty_when_no_connectedfrom(): + wall = Mock() + wall.ConnectedFrom = [] + + assert list(tool.Wall.iter_wall_slab_connections(wall)) == [] + + +# --------------------------------------------------------------------------- +# iter_slab_wall_connections — mirror, walks slab.ConnectedTo +# --------------------------------------------------------------------------- + + +def test_iter_slab_wall_connections_yields_top_rels(): + wall_a = Mock() + wall_b = Mock() + slab = _slab_with_rels( + _rel(related=wall_a), + _rel(related=wall_b), + ) + + result = list(tool.Wall.iter_slab_wall_connections(slab)) + + assert [w for w, _ in result] == [wall_a, wall_b] + + +def test_iter_slab_wall_connections_skips_non_top(): + wall = Mock() + slab = _slab_with_rels( + _rel(description="BOTTOM", related=wall), + _rel(description="TOP", related=wall), + ) + + result = list(tool.Wall.iter_slab_wall_connections(slab)) + + assert len(result) == 1 + + +# --------------------------------------------------------------------------- +# find_wall_slab_rel — locate specific rel between wall + slab +# --------------------------------------------------------------------------- + + +def test_find_wall_slab_rel_returns_match(): + slab_a = Mock(name="slab_a") + slab_b = Mock(name="slab_b") + rel_a = _rel(relating=slab_a) + rel_b = _rel(relating=slab_b) + wall = _wall_with_rels(rel_a, rel_b) + + assert tool.Wall.find_wall_slab_rel(wall, slab_b) is rel_b + + +def test_find_wall_slab_rel_returns_none_when_unconnected(): + slab_a = Mock(name="slab_a") + other_slab = Mock(name="other_slab") + wall = _wall_with_rels(_rel(relating=slab_a)) + + assert tool.Wall.find_wall_slab_rel(wall, other_slab) is None + + +# --------------------------------------------------------------------------- +# wall_slab_connection_location_world — icon anchor point +# --------------------------------------------------------------------------- + + +def test_wall_slab_connection_location_perches_above_wall_top(): + """Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh + bbox top in world space plus WALL_SLAB_CONNECTION_Z_CLEARANCE so the + icon sits above the extend-vertical / slope gizmo at the wall top.""" + wall_obj = Mock() + wall_obj.matrix_world = Matrix.Identity(4) + wall_obj.bound_box = [ + (-0.1, -0.1, 0.0), + (0.1, -0.1, 0.0), + (-0.1, 0.1, 0.0), + (0.1, 0.1, 0.0), + (-0.1, -0.1, 3.0), + (0.1, -0.1, 3.0), + (-0.1, 0.1, 3.0), + (0.1, 0.1, 3.0), + ] + slab_obj = Mock() + + ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0))) + with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line): + loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + + expected_z = 3.0 + tool.Wall.WALL_SLAB_CONNECTION_Z_CLEARANCE + assert loc == Vector((2.0, 0.0, expected_z)) + + +def test_wall_slab_connection_location_returns_none_for_axisless_wall(): + """A wall without an IFC Axis representation has no reference line; the + helper returns None so callers can skip rather than guess a location.""" + wall_obj = Mock() + slab_obj = Mock() + with patch.object(tool.Wall, "get_world_reference_line", return_value=None): + assert tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) is None diff --git a/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py new file mode 100644 index 0000000000..499ce2509f --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py @@ -0,0 +1,67 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch: + +1. Side classification reads the opening's axis-projected midpoint, not + the filling's ``matrix_world.translation``. The filling origin is + flip-fragile — flipping rotates the filler 180° + translates so the + bbox stays visually in place, which would mis-classify a flipped door + centred over the cut. +2. When the void straddles the cut and the filling moves to element2, + the void copy for element1 is taken from the ORIGINAL opening (whose + ``ObjectPlacement`` still references element1), not the rebound + ``new_opening`` (whose ``PlacementRelTo`` was swapped to element2).""" + +import inspect + +import pytest + +pytestmark = pytest.mark.wall + + +def _split_source(): + from bonsai.bim.module.model.wall import DumbWallJoiner + + return inspect.getsource(DumbWallJoiner.split) + + +def test_side_classification_uses_opening_midpoint_not_filling_origin(): + """Side classification must read the opening's axis-projected + midpoint, not the filling's world translation — the latter shifts + under flipping and would mis-classify a flipped door centred over + the cut.""" + source = _split_source() + assert "opening_midpoint" in source + assert "filling_obj.matrix_world.translation" not in source + + +def test_void_copy_reads_from_original_opening_before_remove(): + """When the filling moves to element2 and the void straddles the + cut, element1's pure-void copy must come from the original opening + BEFORE the cleanup that destroys it — the rebound ``new_opening`` + references element2's frame and would shift the void to element1's + origin in element2's local coords.""" + source = _split_source() + branch_start = source.index("if opening_midpoint > cut_percentage:") + branch = source[branch_start:] + add_idx = branch.index("_add_void_copy(element1, opening)") + remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)") + assert add_idx < remove_idx diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py index cd8371e1c3..6715fe8a94 100644 --- a/src/bonsai/test/core/bootstrap.py +++ b/src/bonsai/test/core/bootstrap.py @@ -60,6 +60,13 @@ def collector(): prophet.verify() +@pytest.fixture +def connection(): + prophet = Prophecy(bonsai.core.tool.Connection) + yield prophet + prophet.verify() + + @pytest.fixture def context(): prophet = Prophecy(bonsai.core.tool.Context) diff --git a/src/bonsai/test/core/test_connection.py b/src/bonsai/test/core/test_connection.py new file mode 100644 index 0000000000..5b09bc4dfa --- /dev/null +++ b/src/bonsai/test/core/test_connection.py @@ -0,0 +1,218 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Dispatch tests for ``core.connection.disconnect_rel``. + +The dispatch is the single source of truth for per-kind cleanup shared by the +explicit ``bim.disconnect_elements`` operator and the implicit cascade in +``tool.Geometry.delete_ifc_object``. Each kind has one test that pins which +helpers must be called; the AST forward-compat guard in +``test_connection_forward_compat.py`` then asserts the dispatch table covers +every kind ``Connection.find_rels`` can emit. + +Uses ``unittest.mock`` directly (rather than the Prophecy fixtures) because +the dispatch passes IFC rel entities with attribute access (``rel.RelatingElement``) +that Prophecy's JSON call recorder can't serialize. +""" + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +import bonsai.core.connection as subject + + +def _rel(relating="slab", related="wall"): + return SimpleNamespace(RelatingElement=relating, RelatedElement=related) + + +def _ifc_with_objects(mapping): + ifc = Mock() + ifc.get_object.side_effect = lambda e: mapping.get(e) + ifc.run = Mock() + return ifc + + +class TestDisconnectRelPath: + def test_removes_connection_and_recreates_both_walls(self): + ifc = _ifc_with_objects({"elem_a": "obj_a", "elem_b": "obj_b"}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem_a", partner="elem_b", + ) + + remove.assert_called_once_with(geometry, connection="rel") + model.recreate_wall.assert_any_call("elem_a", "obj_a") + model.recreate_wall.assert_any_call("elem_b", "obj_b") + assert model.recreate_wall.call_count == 2 + + def test_skip_elem_recreate_suppresses_elem_side(self): + """Cascade case: elem is being deleted — don't recreate it.""" + ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem", partner="partner", + skip_elem_recreate=True, + ) + + model.recreate_wall.assert_called_once_with("partner", "partner_obj") + + def test_skip_partner_recreate_suppresses_partner_side(self): + ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem", partner="partner", + skip_partner_recreate=True, + ) + + model.recreate_wall.assert_called_once_with("elem", "elem_obj") + + def test_both_skips_means_only_remove_rel(self): + ifc = _ifc_with_objects({}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem", partner="partner", + skip_elem_recreate=True, + skip_partner_recreate=True, + ) + + remove.assert_called_once() + model.recreate_wall.assert_not_called() + + +class TestDisconnectRelElementTop: + def test_disconnects_then_regenerates_wall(self): + """Operator case (no skip flags): both sides survive, so the wall gets + re-clipped against currently-connected slabs.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + geometry = Mock() + model = Mock() + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, geometry, model, connection, + rel=rel, kind="element-top", elem="elem", partner="partner", + ) + + ifc.run.assert_called_once_with( + "geometry.disconnect_element", relating_element="slab", related_element="wall" + ) + regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"]) + + def test_slab_delete_cascade_still_regenerates_wall(self): + """When slab is being deleted (elem=slab), wall survives and must + re-clip against remaining connections — the cascade's main purpose.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, Mock(), Mock(), connection, + rel=rel, kind="element-top", elem="slab", partner="wall", + skip_elem_recreate=True, # slab is being deleted + ) + + regen.assert_called_once() + + def test_wall_delete_cascade_skips_wall_regen(self): + """When the wall itself is being deleted, regenerating its body moments + before remove_product wipes it is wasted work — skip.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, Mock(), Mock(), connection, + rel=rel, kind="element-top", elem="wall", partner="slab", + skip_elem_recreate=True, # wall is being deleted + ) + + regen.assert_not_called() + ifc.run.assert_called_once() # rel still removed + + def test_both_in_batch_skips_wall_regen(self): + """Batch delete of both endpoints, processing slab first: partner (wall) + also queued for deletion → skip wall regen.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, Mock(), Mock(), connection, + rel=rel, kind="element-top", elem="slab", partner="wall", + skip_elem_recreate=True, + skip_partner_recreate=True, # wall also in batch + ) + + regen.assert_not_called() + + +class TestDisconnectRelElement: + def test_just_removes_the_rel(self): + rel = _rel(relating="A", related="B") + ifc = Mock() + + subject.disconnect_rel( + ifc, Mock(), Mock(), Mock(), + rel=rel, kind="element", elem="elem_a", partner="elem_b", + ) + + ifc.run.assert_called_once_with( + "geometry.disconnect_element", relating_element="A", related_element="B" + ) + + +class TestDisconnectRelUnknownKind: + def test_raises_value_error(self): + with pytest.raises(ValueError, match="Unknown rel kind"): + subject.disconnect_rel( + Mock(), Mock(), Mock(), Mock(), + rel="rel", kind="bogus", elem="a", partner="b", + ) diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py index 7a2f8017d3..e532bacc02 100644 --- a/src/bonsai/test/tool/test_blender.py +++ b/src/bonsai/test/tool/test_blender.py @@ -183,3 +183,16 @@ class TestNpFrombufferLegacy(NewFile): result = subject.np_frombuffer_legacy(data, n) assert result.shape == (n,) np.testing.assert_allclose(result, np.arange(n)) + + +class TestGetObjectFromGuidMissing(NewFile): + """``get_object_from_guid`` must honour its ``Optional[Object]`` return + contract: a GUID that does not resolve in the current IFC file yields + ``None``, not a ``RuntimeError``. Callers iterate stored GUID lists + (array children, library refs, …) and rely on the falsy return to + skip stale entries.""" + + def test_returns_none_when_guid_not_in_file(self): + bpy.ops.bim.create_project() + assert tool.Ifc.get() is not None + assert subject.get_object_from_guid("3iyt7r$Hf4_hQYNhBIDJI4") is None diff --git a/src/bonsai/test/tool/test_cad.py b/src/bonsai/test/tool/test_cad.py index 2e84c71718..51491e259d 100644 --- a/src/bonsai/test/tool/test_cad.py +++ b/src/bonsai/test/tool/test_cad.py @@ -16,7 +16,9 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -from mathutils import Vector +import math + +from mathutils import Matrix, Vector from bonsai.tool.cad import Cad as subject from test.bim.bootstrap import NewFile @@ -88,3 +90,87 @@ class TestClosestPoints(NewFile): edge1 = (V(0, 0, 0), V(0, 0, 0)) edge2 = (V(1, 0, 1), V(2, 0, 2)) assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0]) + + +class TestObbWorldClipPlanes(NewFile): + def test_unit_box_at_origin_returns_axis_aligned_planes(self): + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(1, 1, 1), + ) + assert planes[0] == (-1.0, 0.0, 0.0, 1.0) + assert planes[1] == (1.0, 0.0, 0.0, 1.0) + assert planes[2] == (0.0, -1.0, 0.0, 1.0) + assert planes[3] == (0.0, 1.0, 0.0, 1.0) + assert planes[4] == (0.0, 0.0, -1.0, 1.0) + assert planes[5] == (0.0, 0.0, 1.0, 1.0) + + def test_center_is_inside_all_planes(self): + center = V(5, -3, 2) + planes = subject.obb_world_clip_planes( + center, + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(2, 1, 0.5), + ) + assert subject.point_is_inside_clip_planes(planes, center) + + def test_point_just_outside_positive_x_face_rejected(self): + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(1, 1, 1), + ) + assert subject.point_is_inside_clip_planes(planes, V(0.5, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(1.5, 0, 0)) + + def test_rotated_obb_clips_along_rotated_axes(self): + s = math.sin(math.radians(45)) + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(s, s, 0), V(-s, s, 0), V(0, 0, 1)), + V(1, 1, 1), + ) + assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0)) + + def test_zero_extent_axis_does_not_raise(self): + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(1, 1, 0), + ) + assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0)) + + +class TestObbClipPlanesFromMatrix(NewFile): + def test_identity_matches_unit_box(self): + planes = subject.obb_clip_planes_from_matrix(Matrix.Identity(4)) + assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(2, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(0, -2, 0)) + + def test_translated_host_shifts_clip_region(self): + translated = Matrix.Translation(V(10, 0, 0)) + planes = subject.obb_clip_planes_from_matrix(translated) + assert not subject.point_is_inside_clip_planes(planes, V(0, 0, 0)) + assert subject.point_is_inside_clip_planes(planes, V(10, 0, 0)) + + def test_z_rotation_rotates_box(self): + rot = Matrix.Rotation(math.radians(45), 4, "Z") + planes = subject.obb_clip_planes_from_matrix(rot) + assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0)) + + def test_host_scale_scales_box_extents(self): + scaled = Matrix.Diagonal((2.0, 2.0, 2.0, 1.0)) + planes = subject.obb_clip_planes_from_matrix(scaled) + assert subject.point_is_inside_clip_planes(planes, V(1.9, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(2.1, 0, 0)) + + def test_non_uniform_scale_axis_independent(self): + scaled = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0)) + planes = subject.obb_clip_planes_from_matrix(scaled) + assert subject.point_is_inside_clip_planes(planes, V(2.9, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(3.1, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(0, 1.1, 0)) diff --git a/src/bonsai/test/tool/test_connection_forward_compat.py b/src/bonsai/test/tool/test_connection_forward_compat.py new file mode 100644 index 0000000000..af830353b3 --- /dev/null +++ b/src/bonsai/test/tool/test_connection_forward_compat.py @@ -0,0 +1,123 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a +branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups. + +Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to +``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel`` +would silently regress the disconnect operator and the cascade-on-delete: a new +kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")`` +fallback, and either crash the operator or leave the cascade half-done. This +guard makes the symmetry mandatory at test time.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.model + + +BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai" +TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py" +CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py" + + +def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef: + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == name: + return node + raise AssertionError(f"Function {name!r} not found") + + +def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef: + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == method_name: + return child + raise AssertionError(f"Method {class_name}.{method_name} not found") + + +def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]: + """Extract every kind label this method emits. + + Looks at exactly two narrow patterns to avoid false positives from + docstrings or type-annotation strings: + + - ``_record(rel, "", …)`` — positional string at index 1, the + conventional emit shape in ``find_rels`` / ``find_rels_for_element``. + - ``kind = "" if … else ""`` and chained variants — string + literals on either branch of an ``ast.IfExp`` assigned to ``kind``. + """ + kinds: set[str] = set() + for node in ast.walk(method): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2: + arg = node.args[1] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + kinds.add(arg.value) + elif isinstance(arg, ast.IfExp): + for branch in (arg.body, arg.orelse): + if isinstance(branch, ast.Constant) and isinstance(branch.value, str): + kinds.add(branch.value) + elif isinstance(node, ast.Assign): + targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"] + if not targets or not isinstance(node.value, ast.IfExp): + continue + for branch in (node.value.body, node.value.orelse): + if isinstance(branch, ast.Constant) and isinstance(branch.value, str): + kinds.add(branch.value) + return kinds + + +def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]: + """Return every kind matched by ``disconnect_rel``'s ``kind == "…"`` branches.""" + fn = _find_function(tree, "disconnect_rel") + kinds: set[str] = set() + for node in ast.walk(fn): + if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq): + left = node.left + right = node.comparators[0] + if isinstance(left, ast.Name) and left.id == "kind": + if isinstance(right, ast.Constant) and isinstance(right.value, str): + kinds.add(right.value) + return kinds + + +def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None: + tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8")) + core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8")) + + emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by( + _find_method(tool_tree, "Connection", "find_rels_for_element") + ) + handled = _kind_branches_in_disconnect_rel(core_tree) + + assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed" + + missing = emitted - handled + assert not missing, ( + f"core.connection.disconnect_rel is missing branches for kinds {missing}. " + f"Every kind returned by Connection.find_rels / find_rels_for_element " + f"must have a matching if/elif branch in the dispatch." + ) diff --git a/src/bonsai/test/tool/test_geometry.py b/src/bonsai/test/tool/test_geometry.py index 424e1fd876..5683ce03de 100644 --- a/src/bonsai/test/tool/test_geometry.py +++ b/src/bonsai/test/tool/test_geometry.py @@ -135,6 +135,37 @@ class TestGetRepresentationData(NewFile): assert subject.get_representation_data(representation) == data +class TestGetActiveRepresentation(NewFile): + def test_returns_representation_for_live_id(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + representation = ifc.createIfcShapeRepresentation() + mesh = bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new("Object", mesh) + tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id() + assert subject.get_active_representation(obj) == representation + + def test_returns_none_when_mesh_has_no_id(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh")) + assert subject.get_active_representation(obj) is None + + def test_returns_none_when_id_is_stale(self): + """A representation rebuild can free the old entity while obj.data + still tracks its id. Returning ``None`` keeps every UI redraw alive + instead of spamming ``RuntimeError`` from the by_id lookup.""" + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + representation = ifc.createIfcShapeRepresentation() + mesh = bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new("Object", mesh) + stale_id = representation.id() + tool.Geometry.get_mesh_props(mesh).ifc_definition_id = stale_id + ifc.remove(representation) + assert subject.get_active_representation(obj) is None + + class TestGetRepresentationId(NewFile): def test_run(self): ifc = ifcopenshell.file() diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index fd9e3dfad8..9d21aedc1a 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -672,6 +672,30 @@ class TestUsingArrays(NewFile): pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") assert pset is None, (obj, pset) + def test_remove_array_tolerates_stale_child_guid(self): + """``bim.remove_array`` and the underlying ``regenerate_array`` must + survive a child GUID in ``BBIM_Array.Data`` that no longer resolves + in the file. Real-world IFC files can carry dangling array refs + from external edits — the remove path is meant to delete those + children, so an already-missing entity is the desired terminal + state, not a fatal error.""" + self.setup_array() + parent_obj = bpy.context.active_object + parent_element = tool.Ifc.get_entity(parent_obj) + ifc_file = tool.Ifc.get() + + pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") + data = json.loads(pset["Data"]) + data[0]["children"].append("3iyt7r$Hf4_hQYNhBIDJI4") + ifcopenshell.api.pset.edit_pset( + ifc_file, + pset=ifc_file.by_id(pset["id"]), + properties={"Data": json.dumps(data)}, + ) + + bpy.ops.bim.remove_array(item=0) + assert ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") is None + class TestApplyIfcMaterialChanges(NewFile): def get_used_styles(self, obj: bpy.types.Object) -> set[ifcopenshell.entity_instance]: diff --git a/src/examples/IfcParseExamples.cpp b/src/examples/IfcParseExamples.cpp index 8af17f7717..2951285a60 100644 --- a/src/examples/IfcParseExamples.cpp +++ b/src/examples/IfcParseExamples.cpp @@ -25,8 +25,6 @@ #include "ifcparse/IfcFile.h" #include "ifcparse/IfcLogger.h" -#include INCLUDE_SCHEMA(ifcparse, IfcSchema) -#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema) #include #include @@ -43,6 +41,30 @@ static_assert(false, "A boost preprocessor sequence of schema identifiers is needed for this file to compile."); #endif +// A macro cannot expand to an include directive, so unroll enough includes for +// the maximum number of schemas supported by the build configuration. +#define INCLUDE_SCHEMA_N(n) \ + BOOST_PP_IIF(BOOST_PP_GREATER(BOOST_PP_SEQ_SIZE(SCHEMA_SEQ), n), \ + BOOST_PP_STRINGIZE(ifcparse/BOOST_PP_CAT(Ifc, BOOST_PP_SEQ_ELEM(BOOST_PP_MIN(n, BOOST_PP_SEQ_SIZE(BOOST_PP_SEQ_POP_BACK(SCHEMA_SEQ))), SCHEMA_SEQ)).h), \ + "ifcgeom/empty.h") + +#include INCLUDE_SCHEMA_N(0) +#include INCLUDE_SCHEMA_N(1) +#include INCLUDE_SCHEMA_N(2) +#include INCLUDE_SCHEMA_N(3) +#include INCLUDE_SCHEMA_N(4) +#include INCLUDE_SCHEMA_N(5) +#include INCLUDE_SCHEMA_N(6) +#include INCLUDE_SCHEMA_N(7) +#include INCLUDE_SCHEMA_N(8) +#include INCLUDE_SCHEMA_N(9) +#include INCLUDE_SCHEMA_N(10) +#include INCLUDE_SCHEMA_N(11) +#include INCLUDE_SCHEMA_N(12) +#include INCLUDE_SCHEMA_N(13) +#include INCLUDE_SCHEMA_N(14) +#include INCLUDE_SCHEMA_N(15) + #include #if USE_VLD @@ -58,9 +80,9 @@ struct is_ifc4_or_higher> : s typedef std::map> element_properties; #ifdef SCHEMA_HAS_IfcBuildingElement -typedef IfcSchema::IfcBuildingElement element_t +typedef IfcSchema::IfcBuildingElement element_t; #else -typedef IfcSchema::IfcBuiltElement element_t +typedef IfcSchema::IfcBuiltElement element_t; #endif std::string format_string(const AttributeValue& argument) { @@ -242,7 +264,7 @@ int main(int argc, char** argv) { // we need to cast them to IfcWindows. Since these properties // are optional we need to make sure the properties are // defined for the window in question before accessing them. - example_element_type::list::ptr elements = file.instances_by_type(); + auto elements = file.instances_by_type(); std::cout << "Found " << elements->size() << " elements in " << argv[1] << ":" << std::endl; diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index 5aab765c26..a57e52426c 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -21,6 +21,7 @@ from __future__ import annotations import argparse import datetime +import json import logging import os import time @@ -256,26 +257,31 @@ class IfcDataGetter: return "" if cost_item.CostQuantities is None: return "" - string = "[" + result = [] for quantity in cost_item.CostQuantities: - string += '["' + prefix = "" for rel in file.get_inverse(quantity): if rel.is_a("IfcPropertySet") or rel.is_a("IfcElementQuantity"): - prop_set = rel # Find elements that have this property set - for prop_rel in file.get_inverse(prop_set): + for prop_rel in file.get_inverse(rel): if prop_rel.is_a("IfcRelDefinesByProperties"): for obj in prop_rel.RelatedObjects: if obj.is_a("IfcElement"): - string += obj.Name + " - " - string += quantity.Name + prefix += (obj.Name or "") + " - " + name = prefix + (quantity.Name or "") + # Formula is an optional IfcLabel on IfcQuantity* in IFC4+; absent in + # IFC2X3, hence the schema-safe getattr. + formula = getattr(quantity, "Formula", None) or "" if quantity.is_a("IfcPhysicalSimpleQuantity"): - string += '", ' + str(quantity[3]) + "]," + value = quantity[3] + try: + value = float(value) if value is not None else 0.0 + except (TypeError, ValueError): + value = 0.0 + result.append([name, value, formula]) else: - string += ' ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0],' - string = string.removesuffix(",") - string += "]" - return string + result.append([name + " ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, formula]) + return json.dumps(result, ensure_ascii=False) class SheetData(TypedDict): diff --git a/src/ifc5d/test/test_csv2ifc.py b/src/ifc5d/test/test_csv2ifc.py index c14a795da3..01b4a85ee6 100644 --- a/src/ifc5d/test/test_csv2ifc.py +++ b/src/ifc5d/test/test_csv2ifc.py @@ -17,6 +17,7 @@ # along with IfcOpenShell. If not, see . import csv +import json import tempfile from pathlib import Path @@ -118,3 +119,44 @@ class TestCsv2Ifc: writer.write() assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1 assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1 + + +class TestSerialiseCostQuantities: + def test_quantity_name_with_special_characters_round_trips_as_json(self): + ifc_file = ifcopenshell.file() + name = 'Prospetto est "Np=256,667-23"' + quantity = ifc_file.create_entity("IfcQuantityArea", Name=name, AreaValue=12.5) + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [[name, 12.5, ""]] + + def test_unset_name_does_not_crash(self): + ifc_file = ifcopenshell.file() + # Name left unset so quantity.Name resolves to None at access time. + quantity = ifc_file.create_entity("IfcQuantityArea", AreaValue=3.0) + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [["", 3.0, ""]] + + def test_formula_is_included_when_present(self): + ifc_file = ifcopenshell.file() + quantity = ifc_file.create_entity("IfcQuantityArea", Name="Area", AreaValue=12.5, Formula="Length * Width") + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [["Area", 12.5, "Length * Width"]] + + def test_quantity_without_formula_attribute_does_not_crash(self): + # IfcPhysicalComplexQuantity has no Formula attribute and is unsupported. + ifc_file = ifcopenshell.file() + quantity = ifc_file.create_entity("IfcPhysicalComplexQuantity", Name="Complex", Discrimination="layer") + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [["Complex ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, ""]] diff --git a/src/ifcgeom/abstract_mapping.cpp b/src/ifcgeom/abstract_mapping.cpp index 44cbd7d0b5..350a6df02a 100644 --- a/src/ifcgeom/abstract_mapping.cpp +++ b/src/ifcgeom/abstract_mapping.cpp @@ -45,6 +45,11 @@ ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingF throw IfcParse::IfcException("No geometry mapping registered for " + schema_name_lower); } auto new_mapping = it->second(file, s, logger); - new_mapping->initialize_settings(); + try { + new_mapping->initialize_settings(); + } catch (const std::exception& e) { + logger.Error("GEO", 400, e); + logger.Error("GEO", 401, "Unable to initialize conversion settings"); + } return new_mapping; } diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 7d6592635d..350808ec25 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -54,8 +54,8 @@ ifeq ($(PLATFORM), win64) PLATFORMTAG:=win_amd64 endif -BINARY_VERSION:=0.8.5 -BUILD_COMMIT:=1c5b825 +BINARY_VERSION:=0.8.6 +BUILD_COMMIT:=3e7b739 IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 2c93e8175e..85b310b9ce 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -95,7 +95,9 @@ from .entity_instance import entity_instance, register_schema_attributes from .file import file, rocksdb_lazy_instance from .file import file as _file from .sql import sqlite, sqlite_entity -from .ifcopenshell_wrapper import get_log, logger + +get_log = ifcopenshell_wrapper.get_log +logger = getattr(ifcopenshell_wrapper, "logger", None) # explicitly specify available imported symbols # (it's a requirement for a typed library) @@ -132,10 +134,20 @@ class SchemaError(Error): @overload def open( - path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[False] = False + path: Union[os.PathLike, str], + format: SupportedFormat = None, + *, + should_stream: Literal[False] = False, + logger: Optional[logger] = None, ) -> Union[_file, sqlite]: ... @overload -def open(path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[True]) -> _stream: ... +def open( + path: Union[os.PathLike, str], + format: SupportedFormat = None, + *, + should_stream: Literal[True], + logger: Optional[logger] = None, +) -> _stream: ... @overload def open( path: Union[os.PathLike, str], @@ -143,6 +155,7 @@ def open( *, should_stream: bool = False, readonly: bool = False, + logger: Optional[logger] = None, ) -> Union[_file, sqlite, _stream]: ... def open( path: Union[os.PathLike, str], @@ -151,11 +164,13 @@ def open( readonly: bool = False, mmap: bool = False, bypass_types: Optional[Sequence[str]] = None, + logger: Optional[logger] = None, ) -> Union[_file, sqlite, _stream]: """Loads an IFC dataset from a filepath :param should_stream: Whether to open the file in streaming mode. Could be useful for reading large files. + :param logger: Logger that receives native parser messages. You can specify a file format. If no format is given, it is guessed from its extension. @@ -179,8 +194,10 @@ def open( raise FileNotFoundError(f"Path does not exist: '{path}'.") if format is None: format = guess_format(path) + if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)): + logger = logger_type.Root() if format == ".ifcXML": - f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute())) + f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *((logger,) if logger is not None else ())) if f: return file(f) raise OSError(f"Failed to parse .ifcXML file from {path}") @@ -189,7 +206,7 @@ def open( with zipfile.ZipFile(path) as zf: for name in zf.namelist(): if Path(name).suffix.lower() in (".ifc", ".ifcxml"): - return open(zf.extract(name, unzipped_path)) + return open(zf.extract(name, unzipped_path), logger=logger) else: raise LookupError(f"No .ifc or .ifcXML file found in {path}") if format == ".ifcSQLite": @@ -197,9 +214,11 @@ def open( if should_stream: return stream(path) if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux. - f = ifcopenshell_wrapper.open(str(path.absolute()), readonly=readonly) + f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *((logger,) if logger is not None else ())) elif bypass_types: - f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag()) + f = ifcopenshell_wrapper.file( + ifcopenshell_wrapper.uninitialized_tag(), *((logger,) if logger is not None else ()) + ) for ty in bypass_types: f.bypass_type(ty) if mmap: @@ -209,9 +228,12 @@ def open( f.initialize(str(path.absolute())) elif mmap: # mmap parameter is only available for builds with USE_MMAP, not used in our main builds - f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument] + kwargs = {"mmap": mmap} + if logger is not None: + kwargs["logger"] = logger + f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument] else: - f = ifcopenshell_wrapper.open(str(path.absolute())) + f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ())) return file(f) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index f4ea932933..147a23498d 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -105,7 +105,10 @@ def main( iterators: Sequence[ifcopenshell.geom.iterator] = (), merge_projection: bool = True, progress_function: Callable = DO_NOTHING, + logger=None, ): + if logger is None and ifcopenshell.logger is not None: + logger = ifcopenshell.logger.Root() def by_guid(g): for f in files: @@ -147,7 +150,7 @@ def main( iterator_kwargs["include"] = list( filter(has_selected_parent, sum((f.by_type(x) for x in iterator_kwargs["include"]), [])) ) - return ifcopenshell.geom.iterator(geom_settings, f, **iterator_kwargs) + return ifcopenshell.geom.iterator(geom_settings, f, logger=logger, **iterator_kwargs) # We have to keep the iterator in memory because otherwise # the styles are cleared up. @@ -458,7 +461,6 @@ def main( g1.appendChild(g2) if settings.arrange_spaces or settings.arrange_zones: - if settings.storey_filter: # delete storey groups not selected by filter # sometimes happens in case of elements protruding multiple stories @@ -541,6 +543,7 @@ def main( arranged = W.arrange_polygons( *filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies, # ty: ignore[too-many-positional-arguments] + *((logger,) if logger is not None else ()), ) svg_data_3 = W.polygons_to_svg(arranged, False) dom3 = parseString(svg_data_3) diff --git a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py index ef3c3c3ef0..578e561e79 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py +++ b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py @@ -217,7 +217,8 @@ for id in to_emit: statements.append("%s << %s" % (id, stmt)) if __name__ == "__main__": - print(r""" + print( + r""" # This file is generated by IfcOpenShell ifcexpressparser bootstrap.py from __future__ import annotations @@ -256,4 +257,6 @@ if __name__ == "__main__": mdl = importlib.import_module(output) mdl.Generator(m).emit() sys.stdout.write(m.schema.name) -""" % ("\n ".join(statements))) +""" + % ("\n ".join(statements)) + ) diff --git a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py index 3981dbc421..dd1e96c889 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py +++ b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py @@ -363,18 +363,24 @@ class EarlyBoundCodeWriter: ) ) - self.statements[self.statements.index("{factory_placeholder}")] = """ + self.statements[self.statements.index("{factory_placeholder}")] = ( + """ class %(schema_name)s_instance_factory : public IfcParse::instance_factory { virtual IfcUtil::IfcBaseClass* operator()(const IfcParse::declaration* decl, IfcEntityInstanceData&& data) const { %(instance_mapping)s } }; -""" % locals() +""" + % locals() + ) "" - self.statements[self.statements.index("{string_pool_placeholder}")] = """ + self.statements[self.statements.index("{string_pool_placeholder}")] = ( + """ const std::string strings[] = {%s}; -""" % ",".join(map(lambda s: '"%s"s' % s, self.strings)) +""" + % ",".join(map(lambda s: '"%s"s' % s, self.strings)) + ) def __str__(self): return "\n".join(self.statements) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/app.py b/src/ifcopenshell-python/ifcopenshell/geom/app.py index d6bab207f1..fa07f3f2b8 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/app.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/app.py @@ -145,7 +145,8 @@ class configuration: config.set( "snippets", "print all wall ids", - self.config_encode(""" + self.config_encode( + """ ########################################################################### # A simple script that iterates over all walls in the current model # # and prints their Globally unique IDs (GUIDS) to the console window # @@ -153,13 +154,15 @@ class configuration: for wall in model.by_type("IfcWall"): print ("wall with global id: "+str(wall.GlobalId)) -""".lstrip()), +""".lstrip() + ), ) config.set( "snippets", "print properties of current selection", - self.config_encode(""" + self.config_encode( + """ ########################################################################### # A simple script that iterates over all IfcPropertySets of the currently # # selected object and prints them to the console # @@ -177,7 +180,8 @@ if selection: for prop in relDefinesByProperties.RelatingPropertyDefinition.HasProperties: print ("{:<20} :{}".format(prop.Name,prop.NominalValue.wrappedValue)) print ("\\n") -""".lstrip()), +""".lstrip() + ), ) with open(conf_file, "w") as configfile: config.write(configfile) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 574d19d904..fc66264b95 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -299,13 +299,16 @@ class iterator(ifcopenshell_wrapper.Iterator): include: Optional[Union[list[entity_instance], list[str]]] = None, exclude: Optional[Union[list[entity_instance], list[str]]] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ): self.settings = settings + if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)): + logger = logger_type.Root() if isinstance(file_or_filename, file): self.file = file file_or_filename = file_or_filename.wrapped_data else: - file_or_filename = self.file = open(file_or_filename) + file_or_filename = self.file = open(file_or_filename, logger=logger) if include is not None and exclude is not None: raise ValueError("include and exclude cannot be specified simultaneously") @@ -333,13 +336,18 @@ class iterator(ifcopenshell_wrapper.Iterator): else: initializer = ifcopenshell_wrapper.construct_iterator_with_include_exclude - self.this = initializer( - geometry_library, self.settings, file_or_filename, include_or_exclude, include is not None, num_threads + args = ( + geometry_library, + self.settings, + file_or_filename, + include_or_exclude, + include is not None, + num_threads, ) + self.this = initializer(*args, *((logger,) if logger is not None else ())) else: - self.this = ifcopenshell_wrapper.construct_iterator( - geometry_library, self.settings, file_or_filename, num_threads - ) + args = (geometry_library, self.settings, file_or_filename, num_threads) + self.this = ifcopenshell_wrapper.construct_iterator(*args, *((logger,) if logger is not None else ())) if has_occ: @@ -508,7 +516,11 @@ def create_shape( return wrap_shape_creation( settings, ifcopenshell_wrapper.create_shape( - settings, inst.wrapped_data, repr.wrapped_data if repr is not None else None, geometry_library, *(filter(None, (logger,))) + settings, + inst.wrapped_data, + repr.wrapped_data if repr is not None else None, + geometry_library, + *((logger,) if logger is not None else ()), ), ) @@ -564,6 +576,7 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[IteratorOutput, None, None]: ... @overload def iterate( @@ -577,6 +590,7 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[tuple[int, IteratorOutput], None, None]: ... @overload def iterate( @@ -590,6 +604,7 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: ... def iterate( settings: settings, @@ -602,13 +617,14 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: """Get a geometry iterator for the provided file. :param cache: .h5 cache filepath (might not exist, will be created). :param serializer_settings: Settings for cache serializer. Required if `cache` is provided. """ - it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library) + it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library, logger) if cache: assert serializer_settings, "`serializer_settings` argument is not optional if `cache` is provided." hdf5_cache = serializers.hdf5(cache, settings, serializer_settings) diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index 4354e49e90..fc3de44455 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -355,7 +355,8 @@ def get_cost_rate( class CostValueUnserialiser: def parse(self, formula: str): - l = lark.Lark("""start: formula + l = lark.Lark( + """start: formula formula: operand (operator operand)* operand: value | category "(" formula ")" value: NUMBER? @@ -392,7 +393,8 @@ class CostValueUnserialiser: NEWLINE: (CR? LF)+ %ignore WS // Disregard spaces in text - """) + """ + ) start = l.parse(formula) return self.get_formula(start.children[0]) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index abaa4e4119..d67292fb1d 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -39,7 +39,8 @@ import ifcopenshell.util.shape import ifcopenshell.util.system import ifcopenshell.util.unit -filter_elements_grammar = lark.Lark("""start: filter_group +filter_elements_grammar = lark.Lark( + """start: filter_group filter_group: facet_list ("+" facet_list)* facet_list: facet ("," facet)* @@ -110,9 +111,11 @@ filter_elements_grammar = lark.Lark("""start: filter_group NEWLINE: (CR? LF)+ %ignore WS // Disregard spaces in text -""") +""" +) -get_element_grammar = lark.Lark("""start: keys +get_element_grammar = lark.Lark( + """start: keys keys: key ("." key)* key: quoted_string | regex_string | unquoted_string @@ -127,9 +130,11 @@ get_element_grammar = lark.Lark("""start: keys WS: /[ \\t\\f\\r\\n]/+ %ignore WS // Disregard spaces in text - """) + """ +) -format_grammar = lark.Lark("""start: expression +format_grammar = lark.Lark( + """start: expression ?expression: add_sub ?add_sub: mul_div @@ -188,7 +193,8 @@ format_grammar = lark.Lark("""start: expression NEWLINE: (CR? LF)+ %ignore WS // Disregard spaces in text -""") +""" +) class FormatTransformer(lark.Transformer): diff --git a/src/ifcparse/IfcEntityInstanceData.cpp b/src/ifcparse/IfcEntityInstanceData.cpp index e87c429545..88aa766086 100644 --- a/src/ifcparse/IfcEntityInstanceData.cpp +++ b/src/ifcparse/IfcEntityInstanceData.cpp @@ -34,7 +34,22 @@ namespace { inline T dispatch_get_(AttributeValue::pointer_type array_, uint8_t storage_model_, size_t instance_name_, const IfcParse::declaration* entity_or_type, uint8_t index_) { if (storage_model_ == 0) { - return array_.storage_ptr->get(index_); + try { + return array_.storage_ptr->get(index_); + } catch (const impl::storage_type_mismatch& e) { + throw IfcParse::IfcException( + // entity_or_type not passed, but in v0.9 this is beginning to make sense + (entity_or_type + ? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ") + : std::string("")) + + "Requested type <" + e.requested() + "> does not match actual type <" + e.actual() + "> at index " + std::to_string(index_)); + } catch (const std::out_of_range& e) { + throw IfcParse::IfcException( + (entity_or_type + ? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ") + : std::string("")) + + e.what()); + } } #ifdef IFOPSH_WITH_ROCKSDB else { diff --git a/src/ifcparse/IfcEntityInstanceData.h b/src/ifcparse/IfcEntityInstanceData.h index f51345a25e..237ddb0d35 100644 --- a/src/ifcparse/IfcEntityInstanceData.h +++ b/src/ifcparse/IfcEntityInstanceData.h @@ -72,6 +72,83 @@ class IFC_PARSE_API Derived {}; class IFC_PARSE_API empty_aggregate_t {}; class IFC_PARSE_API empty_aggregate_of_aggregate_t {}; +namespace impl { + template <> + struct VariantTypeName { + static std::string get() { return "null"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "derived"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "int"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "bool"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "logical"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "real"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "string"; } + }; + + template <> + struct VariantTypeName> { + static std::string get() { return "binary"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "enumeration"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "instance"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate"; } + }; + + template + struct VariantTypeName> { + static std::string get() { return "aggregate of " + VariantTypeName::get(); } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate of instance"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate of aggregate"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate of aggregate of instance"; } + }; +} + template struct parameter_pack { static constexpr size_t size = sizeof...(Args); diff --git a/src/ifcparse/IfcLogger.cpp b/src/ifcparse/IfcLogger.cpp index 0b454b8563..200d61e636 100644 --- a/src/ifcparse/IfcLogger.cpp +++ b/src/ifcparse/IfcLogger.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -131,6 +132,31 @@ void json_message(T& out, const IfcUtil::IfcBaseClass* current_product, Logger:: } } // namespace +log_message::log_message( + int severity, + const char (&code_prefix)[4], + uint16_t code_number, + const std::string& timestamp, + const std::string& message, + const IfcUtil::IfcBaseInterface* inst, + const IfcUtil::IfcBaseClass* current_product) + : severity(severity) + , timestamp(timestamp) + , message(message) +{ + snprintf(code, 7, "%s%03u", code_prefix, code_number); + if (inst) { + std::ostringstream oss; + inst->as()->toString(oss); + instance = oss.str(); + } + if (current_product) { + std::ostringstream oss; + current_product->toString(oss); + product = oss.str(); + } +} + Logger& Logger::Root() { static Logger logger; return logger; @@ -199,7 +225,7 @@ void Logger::Message(Logger::Severity type, const char (&code_prefix)[4], uint16 } if (format_ == FMT_INMEMORY) { - log_messages_.emplace_back(type, code_prefix, code_number, message, instance, current_product()); + log_messages_.emplace_back(type, code_prefix, code_number, get_time(), message, instance, current_product()); } else if (((log2_ != nullptr) || (wlog2_ != nullptr))) { if (format_ == FMT_PLAIN) { if (log2_ != nullptr) { diff --git a/src/ifcparse/IfcLogger.h b/src/ifcparse/IfcLogger.h index 19fcdd9738..280cfcd036 100644 --- a/src/ifcparse/IfcLogger.h +++ b/src/ifcparse/IfcLogger.h @@ -37,24 +37,16 @@ class IFC_PARSE_API log_message { public: char code[7]; int severity; - std::string message, instance, product; + std::string timestamp, message, instance, product; - log_message(int severity, const char (&code_prefix)[4], uint16_t code_number, const std::string& message, const IfcUtil::IfcBaseInterface* inst = 0, const IfcUtil::IfcBaseClass* current_product = 0) - : severity(severity) - , message(message) - { - snprintf(code, 7, "%s%03u", code_prefix, code_number); - if (inst) { - std::ostringstream oss; - inst->data().toString(nullptr, nullptr, 0, oss, true); - instance = oss.str(); - } - if (current_product) { - std::ostringstream oss; - current_product->toString(oss); - product = oss.str(); - } - } + log_message( + int severity, + const char (&code_prefix)[4], + uint16_t code_number, + const std::string& timestamp, + const std::string& message, + const IfcUtil::IfcBaseInterface* inst = 0, + const IfcUtil::IfcBaseClass* current_product = 0); }; class IFC_PARSE_API Logger { diff --git a/src/ifcparse/variantarray.h b/src/ifcparse/variantarray.h index 1b00774bf7..b3fcb481b2 100644 --- a/src/ifcparse/variantarray.h +++ b/src/ifcparse/variantarray.h @@ -37,10 +37,28 @@ variant - which is the maximum size of its constituents - is reduced. #include #include #include - -#include "IfcException.h" +#include namespace impl { + class storage_type_mismatch : public std::exception { + private: + std::string requested_, actual__, message_; + + public: + storage_type_mismatch(const std::string& requested, const std::string& actual) + : requested_(requested), actual__(actual), message_("Requested type " + requested_ + " does not match actual type " + actual__) {} + + const char* what() const noexcept override { + return message_.c_str(); + } + + const std::string& requested() const { return requested_; } + const std::string& actual() const { return actual__; } + }; + + template + struct VariantTypeName; + // Trait to detect unique_ptr template struct is_unique_ptr : std::false_type {}; template @@ -166,14 +184,13 @@ public: using U = std::decay_t; static_assert(::impl::TypeIndex_v < sizeof...(Types), "Type not supported by variant"); if (index >= size()) { - throw std::out_of_range("Index out of range"); + throw std::out_of_range("Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())); } destroy_at_index(index); size_and_indices_[index + 1] = ::impl::TypeIndex_v; using V = typename std::tuple_element<::impl::TypeIndex_v, ::impl::MapTypes_t>::type; - // std::wcout << "setting " << index << " to " << typeid(V).name() << " (" << ::impl::TypeIndex_v << ")" << std::endl; if constexpr (::impl::is_unique_ptr::value) { new(&storage_[index]) V(new U(value)); } else { @@ -187,8 +204,8 @@ public: std::size_t index(std::size_t index) const { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } return size_and_indices_[index + 1]; @@ -197,8 +214,8 @@ public: template T& get(std::size_t index) { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } if (!has(index)) { @@ -220,17 +237,16 @@ public: template const T& get(std::size_t index) const { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } if (size_and_indices_[index + 1] != ::impl::TypeIndex::value) { // @todo this IfcException is silly. Figure out what // to do, but at the moment it is specifically caught // in various places. - throw IfcParse::IfcException( - "Type held at index " + std::to_string(index) + " is " + - get_type_name(size_and_indices_[index + 1]) + " and not " + typeid(T).name() + throw impl::storage_type_mismatch( + ::impl::VariantTypeName::get(), get_type_name(size_and_indices_[index + 1]) ); } using V = typename std::tuple_element<::impl::TypeIndex_v, ::impl::MapTypes_t>::type; @@ -244,8 +260,8 @@ public: template auto apply_visitor(Visitor&& visitor, std::size_t index) const { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } return apply_visitor_impl(std::forward(visitor), index, std::integral_constant{}); @@ -312,19 +328,19 @@ private: } template - const char* get_type_name_impl(size_t i) const { + std::string get_type_name_impl(size_t i) const { if constexpr (I == 0) { return ""; } else { if (i == I - 1) { - return typeid(std::tuple_element_t>).name(); + return ::impl::VariantTypeName>>::get(); } else { return get_type_name_impl(i); } } } - const char* get_type_name(size_t i) const { + std::string get_type_name(size_t i) const { return get_type_name_impl(i); } }; diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 90a6743443..947e26ded5 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -3177,9 +3177,9 @@ } }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/src/ifcwrap/CMakeLists.txt b/src/ifcwrap/CMakeLists.txt index 16e3a86565..21c11d3ae0 100644 --- a/src/ifcwrap/CMakeLists.txt +++ b/src/ifcwrap/CMakeLists.txt @@ -17,6 +17,23 @@ # # ################################################################################ +cmake_minimum_required(VERSION 3.21) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(IfcOpenShellPython LANGUAGES CXX) + set(IFCOPENSHELL_IFCWRAP_STANDALONE ON) +else() + set(IFCOPENSHELL_IFCWRAP_STANDALONE OFF) +endif() + +if(NOT DEFINED CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() +if(CMAKE_CXX_STANDARD LESS 17) + message(FATAL_ERROR "C++17 or newer is required.") +endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) + if(POLICY CMP0148) # 3.27 cmake_policy(SET CMP0148 OLD) endif() @@ -24,6 +41,35 @@ if(POLICY CMP0177) # 3.31 cmake_policy(SET CMP0177 OLD) endif() +set(_ifcwrap_feature_definitions "") +if(IFCOPENSHELL_IFCWRAP_STANDALONE) + get_filename_component(_ifcwrap_repo_root "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + list(PREPEND CMAKE_MODULE_PATH "${_ifcwrap_repo_root}/cmake") + + find_package(IfcOpenShell REQUIRED) + + set(WITH_OPENCASCADE "${IFCOPENSHELL_WITH_OPENCASCADE}") + set(WITH_CGAL "${IFCOPENSHELL_WITH_CGAL}") + set(SWIG_DEFINES "") + + if(IFCOPENSHELL_WITH_OPENCASCADE) + list(APPEND SWIG_DEFINES -DIFOPSH_WITH_OPENCASCADE) + list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_OPENCASCADE) + endif() + if(IFCOPENSHELL_WITH_CGAL) + list(APPEND SWIG_DEFINES -DIFOPSH_WITH_CGAL) + list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_CGAL) + endif() + if(IFCOPENSHELL_IFCXML) + list(APPEND SWIG_DEFINES -DWITH_IFCXML) + list(APPEND _ifcwrap_feature_definitions WITH_IFCXML) + endif() + if(IFCOPENSHELL_WITH_ROCKSDB) + list(APPEND SWIG_DEFINES -DIFOPSH_WITH_ROCKSDB) + list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_ROCKSDB) + endif() +endif() + find_package(SWIG) if(NOT SWIG_FOUND) message( @@ -68,7 +114,15 @@ include_directories(BEFORE ${CMAKE_CURRENT_SOURCE_DIR}) set(CMAKE_SWIG_FLAGS ${SWIG_DEFINES}) if(WITH_CGAL) - set(LIBSVGFILL svgfill) + if(IFCOPENSHELL_IFCWRAP_STANDALONE) + if(TARGET IfcOpenShell::svgfill) + set(LIBSVGFILL IfcOpenShell::svgfill) + else() + message(FATAL_ERROR "IfcOpenShell was built with CGAL, but the exported svgfill target was not found.") + endif() + else() + set(LIBSVGFILL svgfill) + endif() endif() set_source_files_properties(IfcPython.i PROPERTIES CPLUSPLUS ON) @@ -113,7 +167,21 @@ else() endif() target_link_libraries(ifcopenshell_wrapper PRIVATE Python::Module) -set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${IFCOPENSHELL_LIBRARIES}) +if(_ifcwrap_feature_definitions) + target_compile_definitions(ifcopenshell_wrapper PRIVATE ${_ifcwrap_feature_definitions}) +endif() +if(IFCOPENSHELL_IFCWRAP_STANDALONE) + set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES}) + set(_ifcwrap_geometry_libraries ${IFCOPENSHELL_GEOMETRY_LIBRARIES}) + set(_ifcwrap_cgal_libraries "") + set(_ifcwrap_swig_depends "") +else() + set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES}) + set(_ifcwrap_geometry_libraries IfcGeom ${kernel_libraries}) + set(_ifcwrap_cgal_libraries ${CGAL_LIBRARIES}) + set(_ifcwrap_swig_depends ${IFCOPENSHELL_LIBRARIES}) +endif() +set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${_ifcwrap_swig_depends}) if(WASM_BUILD) # SIDE_MODULE=1 - add to .so all symbols from linked archives (default used by pyodide). # Since currently libIfcGeom.a seems to be linked twice it results in duplicated symbols and compilation errors. @@ -134,14 +202,14 @@ if("$ENV{LDFLAGS}" MATCHES ".undefined.suppress") # On osx there is some state in the python dylib. With `-Wl,undefined,suppress` we can ignore the missing symbols at compile time. target_link_libraries( ifcopenshell_wrapper - PRIVATE ${IFCOPENSHELL_LIBRARIES} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL} + PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL} ) else() - target_link_libraries(ifcopenshell_wrapper PRIVATE ${IFCOPENSHELL_LIBRARIES} ${LIBSVGFILL}) + target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${LIBSVGFILL}) endif() -target_link_libraries(ifcopenshell_wrapper PRIVATE ${CGAL_LIBRARIES}) -target_link_libraries(ifcopenshell_wrapper PRIVATE IfcGeom ${kernel_libraries}) -if((NOT WIN32) AND BUILD_SHARED_LIBS) +target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_cgal_libraries}) +target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_geometry_libraries}) +if((NOT WIN32) AND BUILD_SHARED_LIBS AND COMMAND SET_INSTALL_RPATHS) SET_INSTALL_RPATHS(ifcopenshell_wrapper "${IFCDIRS};${OCC_LIBRARY_DIR}") endif() @@ -194,7 +262,7 @@ if(Python_Interpreter_FOUND OR PYTHON_MODULE_INSTALL_DIR) endif() endforeach() install( - FILES "${CMAKE_BINARY_DIR}/ifcwrap/ifcopenshell_wrapper.py" + FILES "${CMAKE_CURRENT_BINARY_DIR}/ifcopenshell_wrapper.py" DESTINATION "${python_package_dir}/ifcopenshell" ) install(TARGETS ifcopenshell_wrapper DESTINATION "${python_package_dir}/ifcopenshell") diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index a9cf372dc1..10d9083403 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -637,31 +637,31 @@ struct ShapeRTTI : public boost::static_visitor // I couldn't get the vector typemap to be applied when %extending Iterator constructor. // anyway it does not matter as SWIG generates C code without actual constructors %inline %{ - IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads) { - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, num_threads); - } - - IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) { - std::set elems_set(elems.begin(), elems.end()); - IfcGeom::entity_filter ef{ include, false, elems_set }; - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {ef}, num_threads); - } - - IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) { - std::set elems_set(elems.begin(), elems.end()); - IfcGeom::attribute_filter af; - af.attribute_name = "GlobalId"; - af.populate(elems_set); - af.include = include; - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads); - } - - IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) { - std::set elems_set(elems.begin(), elems.end()); - IfcGeom::instance_id_filter af(include, false, elems_set); - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads); - } -%} + IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads, Logger& logger = Logger::Root()) { + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, num_threads, logger); + } + + IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + std::set elems_set(elems.begin(), elems.end()); + IfcGeom::entity_filter ef{ include, false, elems_set }; + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {ef}, num_threads, logger); + } + + IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + std::set elems_set(elems.begin(), elems.end()); + IfcGeom::attribute_filter af; + af.attribute_name = "GlobalId"; + af.populate(elems_set); + af.include = include; + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger); + } + + IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + std::set elems_set(elems.begin(), elems.end()); + IfcGeom::instance_id_filter af(include, false, elems_set); + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger); + } +%} %extend IfcGeom::Representation::Triangulation { @@ -1288,11 +1288,11 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type } } - std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons) { - std::vector r; - if (svgfill::arrange_polygons(settings, polygons, r)) { - return r; - } else { + std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons, Logger& logger = Logger::Root()) { + std::vector r; + if (svgfill::arrange_polygons(settings, polygons, r, logger)) { + return r; + } else { throw std::runtime_error("Failed to arrange polygons"); } } diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index ab750543ef..9826c03da2 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -712,10 +712,10 @@ private: %newobject stream_from_string; %inline %{ - IfcParse::IfcFile* open(const std::string& fn, bool readonly=false) { + IfcParse::IfcFile* open(const std::string& fn, bool readonly=false, Logger& logger=Logger::Root()) { IfcParse::IfcFile* f; Py_BEGIN_ALLOW_THREADS; - f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly); + f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly, logger); Py_END_ALLOW_THREADS; return f; } @@ -1238,8 +1238,11 @@ private: } %pythoncode %{ severity_string = property(severity_string) + def to_dict(self): + keys = ("timestamp", "severity", "code", "message", "instance", "product") + return dict(zip(keys, self.to_tuple())) def to_tuple(self): - return self.severity_string, self.code, self.message, self.instance + return self.timestamp, self.severity_string, self.code, self.message, self.instance, self.product def __eq__(self, other): return type(self) == type(other) and self.to_tuple() == other.to_tuple() def __hash__(self): diff --git a/src/serializers/CMakeLists.txt b/src/serializers/CMakeLists.txt index 94b30a0e38..b18c8a59c3 100644 --- a/src/serializers/CMakeLists.txt +++ b/src/serializers/CMakeLists.txt @@ -36,7 +36,7 @@ target_link_libraries( IfcParse ) -install(TARGETS Serializers) +install(TARGETS Serializers EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) # Can't use `PUBLIC_HEADER` since we need two folders. install(FILES ${SERIALIZERS_H_FILES} DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/serializers/") diff --git a/src/serializers/schema_dependent/CMakeLists.txt b/src/serializers/schema_dependent/CMakeLists.txt index 73c1e5d3e7..a9c1107bcc 100644 --- a/src/serializers/schema_dependent/CMakeLists.txt +++ b/src/serializers/schema_dependent/CMakeLists.txt @@ -13,6 +13,7 @@ foreach(schema ${SCHEMA_VERSIONS}) if(NOT WASM_BUILD) target_link_libraries(Serializers_ifc${schema} ${OpenCASCADE_LIBRARIES}) endif() + install(TARGETS Serializers_ifc${schema} EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) endforeach() set(SERIALIZER_SCHEMA_LIBRARIES ${SERIALIZER_SCHEMA_LIBRARIES} PARENT_SCOPE) diff --git a/src/svgfill/CMakeLists.txt b/src/svgfill/CMakeLists.txt index 0d9764013a..ea4e64636f 100644 --- a/src/svgfill/CMakeLists.txt +++ b/src/svgfill/CMakeLists.txt @@ -49,7 +49,7 @@ file(GLOB LIB_H_FILES src/*.h) file(GLOB LIB_CPP_FILES src/svgfill.cpp src/arrange_polygons.cpp) set(LIB_SRC_FILES ${LIB_H_FILES} ${LIB_CPP_FILES}) add_library(svgfill ${LIB_SRC_FILES}) -target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL) +target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL IfcParse) set_target_properties(svgfill PROPERTIES PUBLIC_HEADER "${LIB_H_FILES}") add_executable(svgfill_exe src/main.cpp) diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 260b22809e..5a99125adb 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -5,6 +5,8 @@ #include "svgfill.h" #endif +#include "../../ifcparse/IfcLogger.h" + #include #include #include @@ -1602,7 +1604,8 @@ std::map> snap_points_to_box_axes( DebugWriter& debug, const CenterLineGraphData& graph, const std::vector& boxes, - const K::FT& max_projection_distance) { + const K::FT& max_projection_distance, + Logger& logger) { std::vector snapped_points(graph.points.size()); for (size_t i = 0; i < graph.points.size(); ++i) { @@ -1685,7 +1688,11 @@ std::map> snap_points_to_box_axes( debug.write_segment(graph.points[i], best.projection, "snap_candidate_4"); } else { snapped_points[i] = graph.points[i]; - std::cout << "Warning: snapping distance exceeding distance: " << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) << " > " << max_projection_distance << std::endl; + std::ostringstream message; + message << "Snapping distance exceeds maximum distance: " + << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) + << " > " << max_projection_distance; + logger.Message(Logger::LOG_WARNING, "ARR", 1, message.str()); } } @@ -1711,7 +1718,8 @@ Graph2D join_segment_runs( DebugWriter& debug, const std::map>& line_graph, const std::map>& midpoint_to_segment, - const K::FT& max_projection_distance) { + const K::FT& max_projection_distance, + Logger& logger) { auto graph = make_center_line_graph_data(line_graph, midpoint_to_segment); auto runs = runs_from_graph(graph); runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) { @@ -1742,7 +1750,7 @@ Graph2D join_segment_runs( } debug.write_polygons(run_polygons, "merged_boxes"); - auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance); + auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance, logger); return Graph2D(snapped_graph); } @@ -2239,7 +2247,9 @@ extend_end_vertices_based_on_input_simple( DebugWriter& debug_output, const Graph2D& G, const Polygon_list& outer_perimiter, - const K::FT& max_projection_distance, int pass) + const K::FT& max_projection_distance, + int pass, + Logger& logger) { auto max_intersection_distance = max_projection_distance / 4; @@ -2389,9 +2399,9 @@ extend_end_vertices_based_on_input_simple( } } if (within_any_perimeter) { - std::cout << "Within boundary but still no solution given" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 2, "Within boundary but no projection or intersection solution was found"); } else { - std::cout << "Outside of all boundaries" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 3, "Point is outside all boundaries"); } return boost::optional{}; }; @@ -2404,13 +2414,18 @@ extend_end_vertices_based_on_input_simple( auto& M = it->first; if (auto result = process_point(M, *it->second.begin())) { if (*result == M) { - std::cout << "Point already on perimeter (" << M.x() << " " << M.y() << ")" << std::endl; + std::ostringstream message; + message << "Point is already on perimeter (" << M.x() << " " << M.y() << ")"; + logger.Message(Logger::LOG_NOTICE, "ARR", 4, message.str()); continue; } auto d = (M - *result).squared_length(); solutions.emplace_back(d, M, *it->second.begin()); } else { - std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 1] (" << M.x() << " " << M.y() << ")" << std::endl; + std::ostringstream message; + message << "Unable to find projection or intersection point for interior boundary pass " + << pass << " [round 1] (" << M.x() << " " << M.y() << ")"; + logger.Message(Logger::LOG_WARNING, "ARR", 5, message.str()); } } } @@ -2424,12 +2439,17 @@ extend_end_vertices_based_on_input_simple( debug_output.write_segment(point, *result, "exterior_constructed_segment"); auto d = CGAL::squared_distance(point, *result); - std::cout << "Distance: " << std::sqrt(CGAL::to_double(d)) << std::endl; + std::ostringstream message; + message << "Projection or intersection distance: " << std::sqrt(CGAL::to_double(d)); + logger.Message(Logger::LOG_DEBUG, "ARR", 6, message.str()); validation_segments.emplace_back(to_3d(point), to_3d(*result)); auto inserted_it = std::prev(validation_segments.end()); validation_tree.insert(inserted_it, validation_segments.end()); } else { - std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 2] (" << point.x() << " " << point.y() << ")" << std::endl; + std::ostringstream message; + message << "Unable to find projection or intersection point for interior boundary pass " + << pass << " [round 2] (" << point.x() << " " << point.y() << ")"; + logger.Message(Logger::LOG_WARNING, "ARR", 7, message.str()); } } @@ -2528,7 +2548,7 @@ class Segment_2_less { } }; -std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) { +std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right, Logger& logger) { using Walk_pl = CGAL::Arr_walk_along_line_point_location; Walk_pl walk_pl(right); @@ -2610,7 +2630,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2 if (visited_faces_on_right.count(*v) > 0) { // Maybe we should be more permissive, try some other points etc. return_values.push_back(0); - std::cout << "Already visited face on right, skipping point\n"; + logger.Message(Logger::LOG_WARNING, "ARR", 8, "Already visited face on right; skipping point"); } else { // convert arr facet to polygon with holes auto polygon_exterior = circ_to_poly((*v)->outer_ccb()); @@ -2648,7 +2668,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2 max_deviation_poly_pair = {pwh.outer_boundary(), pwh_right.outer_boundary()}; } } else { - std::cout << "No intersection, skipping point\n"; + logger.Message(Logger::LOG_WARNING, "ARR", 9, "No intersection; skipping point"); return_values.push_back(0); } } @@ -2670,7 +2690,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2 return return_values; } -void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) { +void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold, Logger& logger) { using SK = CGAL::Simple_cartesian; CGAL::Cartesian_converter C{}; @@ -2886,7 +2906,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo } } if (!removed) { - std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 10, "Unable to locate edge for removal; skipping"); } } @@ -2971,7 +2991,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo #else auto arr_copy = arr; process_modifications(arr_copy, to_remove_this_path, to_insert_this_path); - auto ious = arrangement_cell_iou(arr, arr_copy); + auto ious = arrangement_cell_iou(debug_output, arr, arr_copy, logger); for (auto& iou : ious) { std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl; } @@ -3303,28 +3323,36 @@ class timer { public: class entry { public: - entry() {} + entry() : logger_(nullptr) {} - entry(std::map::const_iterator start_it) - : start_it(start_it) {} + entry( + std::map::const_iterator start_it, + Logger& logger) + : start_it(start_it) + , logger_(&logger) {} void stop() { - if (start_it) { + if (start_it && logger_) { auto end = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration(end - start_it.value()->second).count(); - std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl; + std::ostringstream message; + message << "Timing for " << start_it.value()->first << ": " << duration << " ms"; + logger_->Message(Logger::LOG_PERF, "ARR", 11, message.str()); } } private: std::optional::const_iterator> start_it; + Logger* logger_; }; - timer(bool enabled = true) : enabled_(enabled) {} + timer(Logger& logger, bool enabled = true) + : logger_(logger) + , enabled_(enabled) {} entry start(const std::string& name) { if (enabled_) { - return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first, logger_); } else { return entry(); } @@ -3336,6 +3364,7 @@ class timer { std::chrono::high_resolution_clock::time_point> timings_; + Logger& logger_; bool enabled_; }; @@ -3351,7 +3380,12 @@ size_t delete_same_facet_edge_pairs(Arrangement_2& arr) { return n_deleted; } -void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { +void arrange_cgal_polygons( + svgfill::arrange_polygon_settings settings, + const std::vector& input_polygons_, + std::vector& output_polygons, + Logger& logger, + double polygon_offset_distance = -1.) { static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1; // even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied @@ -3371,7 +3405,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std debug_output = DebugWriter(false, ""); } - timer timer(settings.debug_output); + timer timer(logger, settings.debug_output); auto t0 = timer.start("input"); @@ -3578,7 +3612,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std for (int i = 0; i < 2; ++i) { auto it = line_graph.find(e.first); if (it == line_graph.end()) { - std::cerr << "Warning: unable to locate vertex for elimination, skipping" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 12, "Unable to locate vertex for elimination; skipping"); continue; } auto& neighbours = it->second; @@ -3607,7 +3641,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std }; if (settings.line_cleaning_algo == 0) { - G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4); + G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4, logger); Arrangement_2 arr; G.to_arrangement(arr); Graph2D G2; @@ -3629,8 +3663,8 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std bool fallback_to_line_cleaning_algo_1 = false; if (settings.line_cleaning_algo == 0) { - segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0); - segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1); + segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0, logger); + segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1, logger); Arrangement_2 arr_clean; G.to_arrangement(arr_clean); @@ -3668,7 +3702,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std debug_output.write_polygons(arr_clean, "iou_left"); debug_output.write_polygons(arr_orig, "iou_right"); - auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig); + auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig, logger); /* for (auto& iou : ious) { std::cout << " " << CGAL::to_double(iou - 1); @@ -3679,7 +3713,10 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std auto it = std::min_element(ious.begin(), ious.end()); if (it != ious.end() && (*it < 0.45)) { - std::cerr << "Significant difference between cleaned and original arrangement, using original for topology reconstruction: " << *it << std::endl; + std::ostringstream message; + message << "Significant difference between cleaned and original arrangement; using original for topology reconstruction: " + << *it; + logger.Message(Logger::LOG_WARNING, "ARR", 13, message.str()); fallback_to_line_cleaning_algo_1 = true; apply_line_cleaning_algo_1(); } else { @@ -3754,7 +3791,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std if (settings.perform_cleanup && settings.line_cleaning_algo != 0) { remove_colinear_vertices(arr); double threshold; - clean_noisy_paths(debug_output, arr, segment_lookup, threshold); + clean_noisy_paths(debug_output, arr, segment_lookup, threshold, logger); remove_colinear_vertices(arr); // clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); } @@ -3773,7 +3810,11 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std #ifndef SVGFILL_MAIN -bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged) { +bool svgfill::arrange_polygons( + arrange_polygon_settings settings, + const std::vector& polygons, + std::vector& arranged, + Logger& logger) { std::vector cgal_polygons, cgal_polygons_out; std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) { Polygon_2 result; @@ -3782,7 +3823,7 @@ bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vec }); return result; }); - arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out); + arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out, logger); std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) { svgfill::polygon_2 result; std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) { @@ -3812,6 +3853,9 @@ Polygon_2 create_rectangle(T x_min, T y_min, T x_max, T y_max) { int main(int argc, char** argv) { std::vector input_polygons, output; + Logger logger; + logger.SetOutput(&std::cout, &std::cerr); + logger.Verbosity(Logger::LOG_PERF); if (argc == 2) { using json = nlohmann::json; @@ -3820,7 +3864,7 @@ int main(int argc, char** argv) { file >> jsonData; size_t i = 0; for (const auto& item : jsonData.items()) { - std::cout << "i " << i << std::endl; + logger.Message(Logger::LOG_NOTICE, "ARR", 14, "Processing arrangement " + std::to_string(i)); i++; input_polygons.clear(); const auto& polygonsData = item.value(); @@ -3832,7 +3876,7 @@ int main(int argc, char** argv) { input_polygons.back().push_back(CGAL::Point_2(x, y)); } } - arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger); break; } return 0; @@ -3845,7 +3889,7 @@ int main(int argc, char** argv) { input_polygons = { rect1, rect2, rect3, rect4, rect5 }; } - arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger); return 0; } diff --git a/src/svgfill/src/svgfill.h b/src/svgfill/src/svgfill.h index 2588636a8d..5bc3fa39e9 100644 --- a/src/svgfill/src/svgfill.h +++ b/src/svgfill/src/svgfill.h @@ -40,6 +40,8 @@ #include #include +class Logger; + namespace svgfill { typedef std::array point_2; typedef std::array line_segment_2; @@ -133,7 +135,7 @@ namespace svgfill { double subdivision_factor = 16.; }; - SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged); + SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged, Logger& logger); } #endif