mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 00:11:36 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3248cc7580 | |||
| f60a3423d0 | |||
| 4f2c4d1633 | |||
| 3b584ef242 | |||
| 10ee5aef4f | |||
| 59383f5010 | |||
| a9d6776875 | |||
| 1057f794f6 | |||
| b3218ced3f | |||
| 49d9c416b1 | |||
| 55e778f82e | |||
| 13681cdf9b | |||
| c592018b3f | |||
| dad4cc8a3c | |||
| 006a24ef32 | |||
| 6fa984ce2b | |||
| 262f5f9a85 | |||
| 44c0c2916c | |||
| 2ab5ca9222 | |||
| f710929e9e | |||
| a2dafc9ceb | |||
| 3c9ee4a71f | |||
| 27b0b920a9 | |||
| 6367de6102 | |||
| 3c20c27794 | |||
| 4f21bd1c69 | |||
| 312be203c9 | |||
| 7c9df9f980 | |||
| 669e04664d | |||
| c0f2fb1860 | |||
| 9396340b13 | |||
| e2a4e5692f | |||
| 104eeaf0cd | |||
| 260a387069 | |||
| 937270fc49 | |||
| 156c6183eb | |||
| 95fcf9e35c | |||
| 855de34d22 | |||
| 1e91eebf51 | |||
| d5bed316cd | |||
| 4d3bff4e3a | |||
| 0b0e34f18d | |||
| 6147a58d7a | |||
| 51eb8aece7 | |||
| 5cc9daa2f9 | |||
| a56b5660d0 | |||
| 5f1efeffaf | |||
| 074021de70 | |||
| ed7239526c | |||
| 7cd7db0c2b | |||
| 13c89ace83 | |||
| 6119f0045e | |||
| bbe437adc8 | |||
| 7dcf415ef1 | |||
| 3f8d7165e5 | |||
| ca1e2165e8 | |||
| 2f067187ee | |||
| 6a6756de66 | |||
| 22707fa534 | |||
| 3e7b739d8d | |||
| ca99ef3af7 | |||
| 55428a0878 | |||
| 682bd0a4f7 | |||
| bb8681a954 | |||
| c7d5d6c498 | |||
| a3593ed58b | |||
| b0eb55cc38 | |||
| f6590d8be2 | |||
| 92aa890add | |||
| 05c9df74f9 | |||
| d1d1e1d4a2 |
@@ -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
|
||||
@@ -0,0 +1,131 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
################################################################################
|
||||
# #
|
||||
# This file is part of IfcOpenShell. #
|
||||
# #
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the Lesser GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3.0 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# IfcOpenShell is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# Lesser GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the Lesser GNU General Public License #
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
################################################################################
|
||||
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/utilities.cmake" OPTIONAL)
|
||||
|
||||
set(_IfcOpenShell_find_args)
|
||||
if(IfcOpenShell_FIND_VERSION)
|
||||
list(APPEND _IfcOpenShell_find_args "${IfcOpenShell_FIND_VERSION}")
|
||||
if(IfcOpenShell_FIND_VERSION_EXACT)
|
||||
list(APPEND _IfcOpenShell_find_args EXACT)
|
||||
endif()
|
||||
endif()
|
||||
list(APPEND _IfcOpenShell_find_args CONFIG QUIET)
|
||||
if(IfcOpenShell_FIND_COMPONENTS)
|
||||
list(APPEND _IfcOpenShell_find_args COMPONENTS ${IfcOpenShell_FIND_COMPONENTS})
|
||||
endif()
|
||||
|
||||
set(_IfcOpenShell_saved_module_path "${CMAKE_MODULE_PATH}")
|
||||
list(REMOVE_ITEM CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}")
|
||||
find_package(IfcOpenShell ${_IfcOpenShell_find_args})
|
||||
set(CMAKE_MODULE_PATH "${_IfcOpenShell_saved_module_path}")
|
||||
|
||||
if(NOT IfcOpenShell_FOUND)
|
||||
set(_IfcOpenShell_error "Could not find an IfcOpenShell CMake config package. Set IfcOpenShell_DIR or CMAKE_PREFIX_PATH.")
|
||||
if(IfcOpenShell_FIND_REQUIRED)
|
||||
message(FATAL_ERROR "${_IfcOpenShell_error}")
|
||||
elseif(NOT IfcOpenShell_FIND_QUIETLY)
|
||||
message(STATUS "${_IfcOpenShell_error}")
|
||||
endif()
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(_IfcOpenShell_required_targets IfcOpenShell::IfcParse IfcOpenShell::IfcGeom)
|
||||
set(_IfcOpenShell_missing_targets "")
|
||||
foreach(_IfcOpenShell_target IN LISTS _IfcOpenShell_required_targets)
|
||||
if(NOT TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND _IfcOpenShell_missing_targets ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_IfcOpenShell_missing_targets)
|
||||
set(IfcOpenShell_FOUND FALSE)
|
||||
string(REPLACE ";" ", " _IfcOpenShell_missing_targets_text "${_IfcOpenShell_missing_targets}")
|
||||
set(_IfcOpenShell_error "IfcOpenShell config was found, but required targets are missing: ${_IfcOpenShell_missing_targets_text}.")
|
||||
if(IfcOpenShell_FIND_REQUIRED)
|
||||
message(FATAL_ERROR "${_IfcOpenShell_error}")
|
||||
elseif(NOT IfcOpenShell_FIND_QUIETLY)
|
||||
message(STATUS "${_IfcOpenShell_error}")
|
||||
endif()
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_OPENCASCADE)
|
||||
set(IFCOPENSHELL_WITH_OPENCASCADE OFF)
|
||||
if(TARGET IfcOpenShell::geometry_kernel_opencascade)
|
||||
set(IFCOPENSHELL_WITH_OPENCASCADE ON)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_CGAL)
|
||||
set(IFCOPENSHELL_WITH_CGAL OFF)
|
||||
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
set(IFCOPENSHELL_WITH_CGAL ON)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_IFCXML)
|
||||
set(IFCOPENSHELL_IFCXML OFF)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_ROCKSDB)
|
||||
set(IFCOPENSHELL_WITH_ROCKSDB OFF)
|
||||
endif()
|
||||
|
||||
set(IFCOPENSHELL_LIBRARIES IfcOpenShell::IfcParse)
|
||||
foreach(_IfcOpenShell_target IN ITEMS IfcOpenShell::geometry_serializer IfcOpenShell::Serializers)
|
||||
if(TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND IFCOPENSHELL_LIBRARIES ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(IFCOPENSHELL_KERNEL_LIBRARIES "")
|
||||
foreach(_IfcOpenShell_target IN ITEMS
|
||||
IfcOpenShell::geometry_kernel_opencascade
|
||||
IfcOpenShell::geometry_kernel_cgal
|
||||
IfcOpenShell::geometry_kernel_cgal_simple
|
||||
)
|
||||
if(TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND IFCOPENSHELL_KERNEL_LIBRARIES ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(IFCOPENSHELL_GEOMETRY_LIBRARIES IfcOpenShell::IfcGeom ${IFCOPENSHELL_KERNEL_LIBRARIES})
|
||||
|
||||
if(TARGET IfcOpenShell::OpenCASCADE_INTERFACE)
|
||||
set(OpenCASCADE_LIBRARIES IfcOpenShell::OpenCASCADE_INTERFACE)
|
||||
endif()
|
||||
|
||||
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
set(CGAL_LIBRARIES IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
endif()
|
||||
|
||||
if(TARGET IfcOpenShell::svgfill)
|
||||
set(IFCOPENSHELL_SVGFILL_LIBRARY IfcOpenShell::svgfill)
|
||||
endif()
|
||||
|
||||
mark_as_advanced(IfcOpenShell_DIR)
|
||||
|
||||
unset(_IfcOpenShell_error)
|
||||
unset(_IfcOpenShell_find_args)
|
||||
unset(_IfcOpenShell_missing_targets)
|
||||
unset(_IfcOpenShell_missing_targets_text)
|
||||
unset(_IfcOpenShell_required_targets)
|
||||
unset(_IfcOpenShell_target)
|
||||
@@ -7,12 +7,26 @@ set(IFCOPENSHELL_WITH_OPENCASCADE @WITH_OPENCASCADE@)
|
||||
set(IFCOPENSHELL_WITH_CGAL @WITH_CGAL@)
|
||||
set(IFCOPENSHELL_IFCXML @IFCXML_SUPPORT@)
|
||||
set(IFCOPENSHELL_WITH_ROCKSDB @WITH_ROCKSDB@)
|
||||
set(IFCOPENSHELL_COLLADA_SUPPORT @COLLADA_SUPPORT@)
|
||||
set(IFCOPENSHELL_GLTF_SUPPORT @GLTF_SUPPORT@)
|
||||
set(IFCOPENSHELL_HDF5_SUPPORT @HDF5_SUPPORT@)
|
||||
set(IFCOPENSHELL_WITH_PROJ @WITH_PROJ@)
|
||||
set(IFCOPENSHELL_USD_SUPPORT @USD_SUPPORT@)
|
||||
|
||||
include(CMakeFindDependencyMacro)
|
||||
|
||||
set(Boost_USE_STATIC_LIBS ON)
|
||||
set(Boost_USE_STATIC_RUNTIME OFF)
|
||||
set(Boost_USE_MULTITHREADED ON)
|
||||
set(IFCOPENSHELL_BOOST_USE_STATIC_LIBS "@Boost_USE_STATIC_LIBS@")
|
||||
set(IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME "@Boost_USE_STATIC_RUNTIME@")
|
||||
set(IFCOPENSHELL_BOOST_USE_MULTITHREADED "@Boost_USE_MULTITHREADED@")
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}" STREQUAL "")
|
||||
set(Boost_USE_STATIC_LIBS ${IFCOPENSHELL_BOOST_USE_STATIC_LIBS})
|
||||
endif()
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}" STREQUAL "")
|
||||
set(Boost_USE_STATIC_RUNTIME ${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME})
|
||||
endif()
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_MULTITHREADED}" STREQUAL "")
|
||||
set(Boost_USE_MULTITHREADED ${IFCOPENSHELL_BOOST_USE_MULTITHREADED})
|
||||
endif()
|
||||
set(Boost_COMPONENTS
|
||||
system
|
||||
program_options
|
||||
@@ -43,13 +57,33 @@ if(IFCOPENSHELL_WITH_ROCKSDB)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_IFCXML)
|
||||
find_dependency(LibXml2 CONFIG)
|
||||
find_dependency(LibXml2)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_CGAL)
|
||||
find_dependency(CGAL CONFIG)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_COLLADA_SUPPORT)
|
||||
find_dependency(OpenCOLLADA)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_GLTF_SUPPORT)
|
||||
find_dependency(nlohmann_json CONFIG)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_HDF5_SUPPORT)
|
||||
find_dependency(HDF5 COMPONENTS C CXX)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_PROJ)
|
||||
find_dependency(PROJ)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_USD_SUPPORT)
|
||||
find_dependency(USD)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_OPENCASCADE)
|
||||
find_dependency(OpenCASCADE CONFIG)
|
||||
if(OpenCASCADE_VERSION VERSION_LESS "7.7.0")
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ endif
|
||||
endif # def PLATFORM
|
||||
|
||||
# Current build commit hash.
|
||||
OLD:=1c5b825
|
||||
OLD:=3e7b739
|
||||
.PHONY: bump
|
||||
bump:
|
||||
ifndef NEW
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -51,9 +51,11 @@ from bonsai.bim.module.model.decorator import (
|
||||
BoundingBoxDecorator,
|
||||
DoorSwingReadonlyDecorator,
|
||||
MEPSegmentExtendPreviewDecorator,
|
||||
MEPSystemPathDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
WallSystemPathDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
@@ -513,6 +515,8 @@ def _install_viewport_overlays() -> None:
|
||||
NestDecorator.uninstall()
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
MEPSystemPathDecorator.uninstall()
|
||||
WallSystemPathDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
BendPreviewDecorator.uninstall()
|
||||
MEPSegmentExtendPreviewDecorator.uninstall()
|
||||
@@ -532,6 +536,9 @@ def _install_viewport_overlays() -> None:
|
||||
WallAxisDecorator.install(bpy.context)
|
||||
if model_props.show_slab_direction:
|
||||
SlabDirectionDecorator.install(bpy.context)
|
||||
if model_props.show_paths:
|
||||
MEPSystemPathDecorator.install(bpy.context)
|
||||
WallSystemPathDecorator.install(bpy.context)
|
||||
if model_props.show_bounding_box:
|
||||
BoundingBoxDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
|
||||
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
|
||||
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
|
||||
copy: bpy.props.BoolProperty(
|
||||
name="Copy",
|
||||
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
|
||||
default=True,
|
||||
)
|
||||
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
|
||||
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
|
||||
gable_roof_edge_angle: bpy.props.FloatProperty(
|
||||
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: int
|
||||
radius: float
|
||||
distance: float
|
||||
copy: bool
|
||||
x: float
|
||||
y: float
|
||||
gable_roof_edge_angle: float
|
||||
|
||||
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
|
||||
elif self.hotkey == "S_O":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "distance")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "copy")
|
||||
|
||||
elif self.hotkey == "S_R":
|
||||
if tool.Geometry.is_profile_object_active():
|
||||
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
|
||||
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
|
||||
|
||||
def hotkey_S_O(self):
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance)
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
|
||||
|
||||
def hotkey_S_Q(self):
|
||||
obj = bpy.context.active_object
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import face_quad, gizmos, operator, prop, ui
|
||||
|
||||
classes = (
|
||||
operator.BIM_OT_add_clip_box,
|
||||
operator.BIM_OT_add_clip_box_for_source,
|
||||
operator.BIM_OT_align_view_to_clip_face,
|
||||
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,
|
||||
face_quad.BIM_GT_box_face_quad,
|
||||
face_quad.BIM_GT_box_face_outline,
|
||||
gizmos.OBJECT_GGT_bim_clip_box,
|
||||
ui.BIM_MT_clip_box_add_for_source,
|
||||
ui.BIM_MT_clip_box_info,
|
||||
ui.BIM_MT_clip_box_settings,
|
||||
ui.BIM_UL_clip_box,
|
||||
ui.BIM_PT_clip_box,
|
||||
)
|
||||
|
||||
|
||||
@persistent
|
||||
def _on_depsgraph_update(scene, depsgraph):
|
||||
tool.ClipBox.on_depsgraph_update(scene, depsgraph)
|
||||
tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph)
|
||||
|
||||
|
||||
@persistent
|
||||
def _on_load_post(filepath):
|
||||
# Restore the per-scene clip-box list from the project's BBIM_ClipBoxes
|
||||
# pset. Runs after the standard load_post that creates Blender objects.
|
||||
tool.ClipBox._last_seen_object_matrices.clear()
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
|
||||
|
||||
_draw_handler_pre = None
|
||||
_draw_handler_post = None
|
||||
|
||||
|
||||
def register():
|
||||
global _draw_handler_pre, _draw_handler_post
|
||||
bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties)
|
||||
bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties)
|
||||
tool.ClipBox.reset_ownership()
|
||||
if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update)
|
||||
if _on_load_post not in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.append(_on_load_post)
|
||||
if _draw_handler_pre is None:
|
||||
_draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW")
|
||||
if _draw_handler_post is None:
|
||||
_draw_handler_post = bpy.types.SpaceView3D.draw_handler_add(
|
||||
tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW"
|
||||
)
|
||||
|
||||
|
||||
def unregister():
|
||||
global _draw_handler_pre, _draw_handler_post
|
||||
if _draw_handler_post is not None:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
_draw_handler_post = None
|
||||
if _draw_handler_pre is not None:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
_draw_handler_pre = None
|
||||
if _on_load_post in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.remove(_on_load_post)
|
||||
if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
tool.ClipBox._last_seen_object_matrices.clear()
|
||||
tool.ClipBox.clear_clip_planes()
|
||||
del bpy.types.Object.BIMClipBoxProperties
|
||||
del bpy.types.Scene.BIMSceneClipBoxProperties
|
||||
@@ -0,0 +1,212 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""EnumProperty ``items=`` callbacks for the source-based clip-box picker.
|
||||
|
||||
Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is
|
||||
an IFC entity id stringified for entity-driven kinds, an IFC class name for
|
||||
``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator
|
||||
turns the picked id into a ``matrix_world`` via the source-preset helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
EnumItems = list[tuple[str, str, str]]
|
||||
|
||||
# Module-level cache. Blender's EnumProperty stores raw char pointers from the
|
||||
# tuples a callback returns, so the Python strings must outlive the draw call.
|
||||
# Stashing the latest result per kind keeps them alive across callback firings.
|
||||
_items_cache: dict[str, EnumItems] = {}
|
||||
|
||||
# Sentinel id used for the "no options available" placeholder. The operator
|
||||
# treats this as an invalid pick and surfaces an ERROR.
|
||||
NO_OPTIONS_ID = "__none__"
|
||||
|
||||
|
||||
def _cache(kind: str, items: EnumItems) -> EnumItems:
|
||||
_items_cache[kind] = items
|
||||
return items
|
||||
|
||||
|
||||
def _no_options(label: str) -> EnumItems:
|
||||
# Blender refuses to draw an EnumProperty with zero entries — show a
|
||||
# placeholder so the dialog renders and the user sees the empty state.
|
||||
return [(NO_OPTIONS_ID, label, "")]
|
||||
|
||||
|
||||
def _label(entity, ifc_class: str | None = None) -> str:
|
||||
name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
|
||||
return f"{ifc_class}: {name}" if ifc_class else name
|
||||
|
||||
|
||||
def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems:
|
||||
"""Shared shape for the IFC-driven enum callbacks.
|
||||
|
||||
Returns the no-IFC placeholder if no file is loaded, then runs
|
||||
``build_fn(ifc_file)``, sorts the result alphabetically by label, and
|
||||
returns the empty-result placeholder if nothing matched. The output is
|
||||
always routed through the module cache.
|
||||
"""
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache(kind, _no_options("No IFC loaded"))
|
||||
items = build_fn(ifc)
|
||||
items.sort(key=lambda t: t[1].lower())
|
||||
if not items:
|
||||
return _cache(kind, _no_options(empty_label))
|
||||
return _cache(kind, items)
|
||||
|
||||
|
||||
# Top-down spatial hierarchy so the picker reads in the order an architect
|
||||
# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded
|
||||
# — spaces are typically empty volumes used for room metadata, so clipping to
|
||||
# one rarely matches the user intent of "show me what's in this container".
|
||||
SPATIAL_CLASSES: tuple[str, ...] = (
|
||||
"IfcProject",
|
||||
"IfcSite",
|
||||
"IfcBuilding",
|
||||
"IfcBuildingStorey",
|
||||
)
|
||||
|
||||
|
||||
def spatial_items(self, context) -> EnumItems:
|
||||
# Special-case: per-class sort within the hierarchy order rather than a
|
||||
# flat alphabetical sort, so the dropdown reads project → site → building.
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache("SPATIAL", _no_options("No IFC loaded"))
|
||||
items: EnumItems = []
|
||||
for ifc_class in SPATIAL_CLASSES:
|
||||
try:
|
||||
entities = ifc.by_type(ifc_class, include_subtypes=False)
|
||||
except RuntimeError:
|
||||
continue
|
||||
for entity in sorted(entities, key=lambda e: (e.Name or "").lower()):
|
||||
items.append((str(entity.id()), _label(entity, ifc_class), ""))
|
||||
if not items:
|
||||
return _cache("SPATIAL", _no_options("No spatial containers"))
|
||||
return _cache("SPATIAL", items)
|
||||
|
||||
|
||||
def class_items(self, context) -> EnumItems:
|
||||
# Special-case: the picker value IS the IFC class name, not an entity id,
|
||||
# so the build shape differs from the other entity-driven callbacks.
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache("CLASS", _no_options("No IFC loaded"))
|
||||
# List only IFC classes ACTUALLY present in the file (not the whole
|
||||
# schema), so the user picks from classes that can produce a non-empty
|
||||
# clip volume. ``e.is_a()`` returns the most specific class per element.
|
||||
present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")})
|
||||
if not present:
|
||||
return _cache("CLASS", _no_options("No products"))
|
||||
return _cache("CLASS", [(cls, cls, "") for cls in present])
|
||||
|
||||
|
||||
def type_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"TYPE",
|
||||
"No types defined",
|
||||
lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")],
|
||||
)
|
||||
|
||||
|
||||
def material_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"MATERIAL",
|
||||
"No materials defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")],
|
||||
)
|
||||
|
||||
|
||||
def profile_items(self, context) -> EnumItems:
|
||||
# ProfileName is optional. Skip unnamed profiles — they can't be
|
||||
# meaningfully picked from a flat list.
|
||||
return _build_items(
|
||||
"PROFILE",
|
||||
"No named profiles",
|
||||
lambda ifc: [
|
||||
(str(e.id()), f"{e.is_a()}: {e.ProfileName}", "")
|
||||
for e in ifc.by_type("IfcProfileDef")
|
||||
if getattr(e, "ProfileName", None)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def drawing_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"DRAWING",
|
||||
"No drawings defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcAnnotation") if e.ObjectType == "DRAWING"],
|
||||
)
|
||||
|
||||
|
||||
# Display labels for each status value. The id strings on the left are the
|
||||
# canonical Pset_*Common.Status enum values accepted by Bonsai's status query.
|
||||
STATUS_LABELS: tuple[tuple[str, str], ...] = (
|
||||
("No Status", "No Status"),
|
||||
("NEW", "New"),
|
||||
("EXISTING", "Existing"),
|
||||
("DEMOLISH", "Demolish"),
|
||||
("TEMPORARY", "Temporary"),
|
||||
("OTHER", "Other"),
|
||||
("NOTKNOWN", "Not Known"),
|
||||
("UNSET", "Unset"),
|
||||
)
|
||||
|
||||
|
||||
def status_items(self, context) -> EnumItems:
|
||||
# Fixed enum; no IFC needed. Still routed through the cache to share the
|
||||
# same string-lifetime guarantee as the other callbacks.
|
||||
return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS])
|
||||
|
||||
|
||||
def system_items(self, context) -> EnumItems:
|
||||
# IfcStructuralAnalysisModel is a structural-grouping container, not a
|
||||
# distribution system — excluded to match Bonsai's other system pickers.
|
||||
return _build_items(
|
||||
"SYSTEM",
|
||||
"No systems defined",
|
||||
lambda ifc: [
|
||||
(str(e.id()), _label(e, e.is_a()), "")
|
||||
for e in ifc.by_type("IfcSystem")
|
||||
if not e.is_a("IfcStructuralAnalysisModel")
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def group_items(self, context) -> EnumItems:
|
||||
# include_subtypes=False so IfcSystem and IfcZone instances don't appear
|
||||
# under Group as well — those get their own picker entries.
|
||||
return _build_items(
|
||||
"GROUP",
|
||||
"No groups defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcGroup", include_subtypes=False)],
|
||||
)
|
||||
|
||||
|
||||
def zone_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"ZONE",
|
||||
"No zones defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")],
|
||||
)
|
||||
@@ -0,0 +1,879 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Generic face-quad resize gizmos for any axis-aligned local box.
|
||||
|
||||
This module contains the box-agnostic core of the interactive
|
||||
face-resize gizmos: two Gizmo classes (a near-invisible click target
|
||||
welded to each face, and a thin colored edge outline), a per-redraw
|
||||
orchestrator that places six of each on a box, and the pure one-sided
|
||||
resize arithmetic. None of it knows about IFC, clip boxes, or
|
||||
``BIMSceneClipBoxProperties`` — a future camera-view-box adapter can
|
||||
reuse the same classes and helpers.
|
||||
|
||||
Consumer contract — the adapter group must:
|
||||
|
||||
1. Create six ``BIM_GT_box_face_quad`` and six ``BIM_GT_box_face_outline``
|
||||
instances at ``setup()`` time, in :data:`FACE_ROUTES` order, and bind
|
||||
each quad's ``move_get_cb`` / ``move_set_cb`` to closures that read
|
||||
and mutate the box's host (e.g. an Empty's ``location`` / ``scale``).
|
||||
2. Call :func:`apply_face_quad_layout` from ``refresh()`` /
|
||||
``draw_prepare()`` with the box's local-frame ``bmin`` / ``bmax``,
|
||||
the host's ``matrix_world``, the OBB rotation as a 4x4
|
||||
(``Matrix.Identity(4)`` when the rotation rides in ``matrix_world``),
|
||||
and the current ``region`` / ``rv3d``.
|
||||
3. Implement ``_lock_for(active_gz)`` / ``_unlock_all()`` on the group
|
||||
for drag mutual exclusion; the quad's ``invoke`` / ``exit`` call them.
|
||||
|
||||
The resize arithmetic in :func:`compute_face_resize` is pure: feed it
|
||||
the modal scalar plus drag-start snapshots and it returns the host's
|
||||
new scale-on-axis and new origin location.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public iteration order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# (axis, is_max) pairs. The adapter group's ``setup()`` MUST create its
|
||||
# six face-quad gizmos in this order so positional indexing into the
|
||||
# layout helper stays correct.
|
||||
FACE_ROUTES: tuple[tuple[int, bool], ...] = (
|
||||
(0, False),
|
||||
(0, True),
|
||||
(1, False),
|
||||
(1, True),
|
||||
(2, False),
|
||||
(2, True),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public visual constants (adapter reads these in setup())
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Standard XYZ axis colors (Blender convention).
|
||||
AXIS_COLOR: dict[int, tuple[float, float, float]] = {
|
||||
0: (1.0, 0.2, 0.2),
|
||||
1: (0.2, 1.0, 0.2),
|
||||
2: (0.2, 0.4, 1.0),
|
||||
}
|
||||
|
||||
# Documented "selectable but unpainted" trick: the GPU still writes the
|
||||
# selection buffer at this alpha so clicks register, but no visible
|
||||
# pixels are produced.
|
||||
FACE_QUAD_ALPHA: float = 0.001
|
||||
|
||||
# Very faint hover tint — just enough to confirm "you're aiming at this
|
||||
# face" without painting visibly over geometry behind it.
|
||||
FACE_QUAD_ALPHA_HIGHLIGHT: float = 0.04
|
||||
|
||||
# Setup-time default for ``select_bias``; the layout helper overwrites
|
||||
# it per frame to the front-facing or halo value below. Kept below the
|
||||
# canonical arrow bias so a bailed frame can't let a front quad steal
|
||||
# clicks meant for a hidden control.
|
||||
FACE_QUAD_SELECT_BIAS: float = 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Unit quad in the local XY plane spanning [-0.5, 0.5]^2 at z=0. Two
|
||||
# CCW triangles viewed from +Z. matrix_basis stretches it onto the
|
||||
# face's perpendicular extents.
|
||||
_QUAD_TRIS: list[tuple[float, float, float]] = [
|
||||
(-0.5, -0.5, 0.0),
|
||||
(0.5, -0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(-0.5, -0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(-0.5, 0.5, 0.0),
|
||||
]
|
||||
|
||||
# Unit-quad outline as 4 line segments in the local XY plane at z=0.
|
||||
_QUAD_OUTLINE_LINES: list[tuple[float, float, float]] = [
|
||||
(-0.5, -0.5, 0.0),
|
||||
(0.5, -0.5, 0.0),
|
||||
(0.5, -0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(-0.5, 0.5, 0.0),
|
||||
(-0.5, 0.5, 0.0),
|
||||
(-0.5, -0.5, 0.0),
|
||||
]
|
||||
|
||||
# Degenerate zero-area triangle for hidden back-facing quads with no
|
||||
# visible-adjacent neighbours (rare orientation). Blender tolerates
|
||||
# this; the gizmo is hidden anyway so nothing renders.
|
||||
_EMPTY_TRIS: list[tuple[float, float, float]] = [
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
]
|
||||
|
||||
# Rotates the gizmo's local +Z onto the outward face normal in the
|
||||
# box's local frame. Right-hand rotation around the named axis.
|
||||
_AXIS_ORIENT: dict[tuple[int, bool], Matrix] = {
|
||||
(0, False): Matrix.Rotation(-math.pi / 2, 4, "Y"),
|
||||
(0, True): Matrix.Rotation(math.pi / 2, 4, "Y"),
|
||||
(1, False): Matrix.Rotation(math.pi / 2, 4, "X"),
|
||||
(1, True): Matrix.Rotation(-math.pi / 2, 4, "X"),
|
||||
(2, False): Matrix.Rotation(math.pi, 4, "X"),
|
||||
(2, True): Matrix.Identity(4),
|
||||
}
|
||||
|
||||
# Per-face mapping from face-quad local axes to local box axes for the
|
||||
# perpendicular-extent scale. ``(w_axis, h_axis)`` — the box-local axis
|
||||
# indices the quad's local X and Y span after the orientation rotation.
|
||||
_QUAD_PERP_AXES: dict[tuple[int, bool], tuple[int, int]] = {
|
||||
(0, False): (2, 1),
|
||||
(0, True): (2, 1),
|
||||
(1, False): (0, 2),
|
||||
(1, True): (0, 2),
|
||||
(2, False): (0, 1),
|
||||
(2, True): (0, 1),
|
||||
}
|
||||
|
||||
# Front-facing quad sits ABOVE the halo strips so the cursor on the
|
||||
# visible face area always grabs the visible face, never accidentally
|
||||
# routes to a back-face halo strip in an adjacent screen region.
|
||||
_FACE_QUAD_FRONT_FACING_SELECT_BIAS: float = 1.5
|
||||
_FACE_QUAD_HALO_FRAME_SELECT_BIAS: float = 1.0
|
||||
|
||||
# Target halo-strip thickness in screen pixels. The world-space margin
|
||||
# is recomputed per frame so the rim stays a roughly constant on-screen
|
||||
# size regardless of viewport zoom.
|
||||
_FACE_QUAD_HALO_TARGET_PIXELS: float = 20.0
|
||||
|
||||
# Minimum world half-extent a face resize may shrink to. Stops a drag
|
||||
# from collapsing the host to zero or negative scale.
|
||||
_MIN_HALF_EXTENT: float = 1e-4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure predicates (testable without Blender)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Vec3 = tuple[float, float, float]
|
||||
|
||||
|
||||
def face_outward_axis_local(axis: int, is_max: bool) -> Vec3:
|
||||
"""Un-rotated outward face normal in the box's local AABB coords.
|
||||
|
||||
For ``(axis=0, is_max=True)`` returns ``(+1, 0, 0)``; for the −X
|
||||
face ``(-1, 0, 0)``; etc. The rotated world normal is obtained by
|
||||
applying the host's rotation and the OBB rotation:
|
||||
``mw_rot @ cage_rotation @ this``.
|
||||
"""
|
||||
sign = 1.0 if is_max else -1.0
|
||||
out = [0.0, 0.0, 0.0]
|
||||
out[axis] = sign
|
||||
return (out[0], out[1], out[2])
|
||||
|
||||
|
||||
def front_facing_face_mask(
|
||||
face_normals_world: Sequence[Vec3],
|
||||
view_dir_world: Vec3,
|
||||
eps: float = 1e-6,
|
||||
) -> tuple[bool, ...]:
|
||||
"""Which of the 6 box faces point toward the camera.
|
||||
|
||||
A face is front-facing iff its outward normal points AGAINST the
|
||||
view direction (``dot(normal, view_dir) < -eps``). The ``-eps``
|
||||
margin prevents flicker at grazing angles.
|
||||
|
||||
``face_normals_world`` must be in :data:`FACE_ROUTES` order; returns
|
||||
a 6-tuple of bool parallel to that order.
|
||||
"""
|
||||
if len(face_normals_world) != 6:
|
||||
msg = f"expected 6 face normals, got {len(face_normals_world)}"
|
||||
raise ValueError(msg)
|
||||
vx, vy, vz = view_dir_world
|
||||
return tuple((n[0] * vx + n[1] * vy + n[2] * vz) < -eps for n in face_normals_world)
|
||||
|
||||
|
||||
def view_axis_parallel_face_mask(
|
||||
face_normals_world: Sequence[Vec3],
|
||||
view_dir_world: Vec3,
|
||||
threshold: float = 0.95,
|
||||
) -> tuple[bool, ...]:
|
||||
"""Which faces have normals (anti-)parallel to the view direction.
|
||||
|
||||
True iff ``abs(dot(normal, view_dir)) >= threshold`` — i.e. the
|
||||
face is nearly perpendicular to the screen plane. Provided as a
|
||||
pure predicate for callers that want to detect degenerate-drag
|
||||
conditions; the layout helper itself no longer gates on it.
|
||||
"""
|
||||
if len(face_normals_world) != 6:
|
||||
msg = f"expected 6 face normals, got {len(face_normals_world)}"
|
||||
raise ValueError(msg)
|
||||
vx, vy, vz = view_dir_world
|
||||
return tuple(abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure resize arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_face_resize(
|
||||
*,
|
||||
value: float,
|
||||
init_world_half: float,
|
||||
init_location: tuple[float, float, float],
|
||||
world_axis: tuple[float, float, float],
|
||||
display_size: float,
|
||||
) -> tuple[float, tuple[float, float, float]]:
|
||||
"""Pure one-sided face-resize arithmetic.
|
||||
|
||||
Returns ``(new_scale_axis, new_location)`` — the host's new scale
|
||||
on the dragged axis and its new world origin — such that the
|
||||
dragged face moves by the modal's outward delta while the OPPOSITE
|
||||
face stays put.
|
||||
|
||||
``value`` is ``init + delta``, where ``init`` is the unsigned
|
||||
drag-start world half-extent and ``delta`` is the cursor projection
|
||||
onto the face's OUTWARD world normal. Realized half-extent is
|
||||
clamped to a small floor; the location shift uses the realized
|
||||
(post-clamp) delta so the opposite face stays fixed even at the
|
||||
clamp.
|
||||
"""
|
||||
face_delta = value - init_world_half
|
||||
new_world_half = init_world_half + 0.5 * face_delta
|
||||
if new_world_half < _MIN_HALF_EXTENT:
|
||||
new_world_half = _MIN_HALF_EXTENT
|
||||
realized_delta = 2.0 * (new_world_half - init_world_half)
|
||||
|
||||
ds = display_size if display_size != 0.0 else 1.0
|
||||
new_scale_axis = new_world_half / ds
|
||||
shift = 0.5 * realized_delta
|
||||
new_location = (
|
||||
init_location[0] + shift * world_axis[0],
|
||||
init_location[1] + shift * world_axis[1],
|
||||
init_location[2] + shift * world_axis[2],
|
||||
)
|
||||
return new_scale_axis, new_location
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal geometry helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_face_quad_scale(bmin: Any, bmax: Any, axis: int, is_max: bool) -> tuple[float, float]:
|
||||
"""Return ``(w, h)`` for the face quad's scale matrix."""
|
||||
w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)]
|
||||
w = float(bmax[w_axis] - bmin[w_axis])
|
||||
h = float(bmax[h_axis] - bmin[h_axis])
|
||||
return w, h
|
||||
|
||||
|
||||
def _shared_edge_corner_keys(
|
||||
axis_a: int, is_max_a: bool, axis_b: int, is_max_b: bool
|
||||
) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None:
|
||||
"""Return the 2 corner-bit triples shared by two adjacent faces.
|
||||
|
||||
Corner keys are 3-tuples of bits (0 = bmin, 1 = bmax). The two
|
||||
returned corners are ordered with the free-axis bit ascending.
|
||||
"""
|
||||
if axis_a == axis_b:
|
||||
return None
|
||||
free_axis = 3 - axis_a - axis_b
|
||||
bit_a = 1 if is_max_a else 0
|
||||
bit_b = 1 if is_max_b else 0
|
||||
corner_lo = [0, 0, 0]
|
||||
corner_hi = [0, 0, 0]
|
||||
corner_lo[axis_a] = bit_a
|
||||
corner_hi[axis_a] = bit_a
|
||||
corner_lo[axis_b] = bit_b
|
||||
corner_hi[axis_b] = bit_b
|
||||
corner_lo[free_axis] = 0
|
||||
corner_hi[free_axis] = 1
|
||||
return (
|
||||
(corner_lo[0], corner_lo[1], corner_lo[2]),
|
||||
(corner_hi[0], corner_hi[1], corner_hi[2]),
|
||||
)
|
||||
|
||||
|
||||
def _face_corner_keys(axis: int, is_max: bool) -> tuple[
|
||||
tuple[int, int, int],
|
||||
tuple[int, int, int],
|
||||
tuple[int, int, int],
|
||||
tuple[int, int, int],
|
||||
]:
|
||||
"""Return the 4 corner-bit triples of a face in CCW order.
|
||||
|
||||
Triangulation as ``[(0,1,2), (0,2,3)]`` covers the whole face with
|
||||
two non-overlapping triangles.
|
||||
"""
|
||||
fixed_bit = 1 if is_max else 0
|
||||
free_axes = [a for a in (0, 1, 2) if a != axis]
|
||||
fa0, fa1 = free_axes
|
||||
corners = []
|
||||
for ka, kb in ((0, 0), (1, 0), (1, 1), (0, 1)):
|
||||
key = [0, 0, 0]
|
||||
key[axis] = fixed_bit
|
||||
key[fa0] = ka
|
||||
key[fa1] = kb
|
||||
corners.append((key[0], key[1], key[2]))
|
||||
return (corners[0], corners[1], corners[2], corners[3])
|
||||
|
||||
|
||||
def _build_strip_tris_relative(
|
||||
edge_p0_local: tuple[float, float, float],
|
||||
edge_p1_local: tuple[float, float, float],
|
||||
extrusion_local: tuple[float, float, float],
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Build two CCW triangles (6 vertices) for a thin halo strip.
|
||||
|
||||
All inputs are in coords relative to the gizmo's ``matrix_basis``
|
||||
anchor. The strip runs along ``[edge_p0_local, edge_p1_local]`` and
|
||||
extrudes by ``extrusion_local`` perpendicular to the edge.
|
||||
"""
|
||||
p0x, p0y, p0z = edge_p0_local
|
||||
p1x, p1y, p1z = edge_p1_local
|
||||
ex, ey, ez = extrusion_local
|
||||
p0e = (p0x + ex, p0y + ey, p0z + ez)
|
||||
p1e = (p1x + ex, p1y + ey, p1z + ez)
|
||||
return [
|
||||
(p0x, p0y, p0z),
|
||||
p0e,
|
||||
p1e,
|
||||
(p0x, p0y, p0z),
|
||||
p1e,
|
||||
(p1x, p1y, p1z),
|
||||
]
|
||||
|
||||
|
||||
def _strips_geometry_changed(quad_gz, face_quad_local, all_tris) -> bool:
|
||||
"""True if the back-face quad's geometry differs from the cached upload.
|
||||
|
||||
Pure orbit/pan doesn't change either the box pose or the cage
|
||||
rotation, so the computed strip vertices are byte-identical to the
|
||||
previous frame's. Hitting the cache lets the back-facing branch
|
||||
skip ``new_custom_shape`` and the GPU upload.
|
||||
"""
|
||||
cached = getattr(quad_gz, "_strips_cache_key", None)
|
||||
last_state = getattr(quad_gz, "_last_geometry_state", None)
|
||||
key = (face_quad_local, all_tris)
|
||||
if cached is None or last_state != "strips" or cached != key:
|
||||
quad_gz._strips_cache_key = key
|
||||
quad_gz._last_geometry_state = "strips"
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _compute_face_basis(
|
||||
mw: Any,
|
||||
mw_rot: Any,
|
||||
cage_rotation: Any,
|
||||
pivot_local: Any,
|
||||
face_local: Any,
|
||||
orient: Any,
|
||||
) -> tuple[Any, Any]:
|
||||
"""World-space (translation, outward-normal-direction) for one face."""
|
||||
rotated_face_local = cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local
|
||||
face_world = mw @ rotated_face_local
|
||||
world_axis = (mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0)))).normalized()
|
||||
return face_world, world_axis
|
||||
|
||||
|
||||
def _compose_face_matrix_basis(
|
||||
face_world: Any,
|
||||
mw_rot_scale: Any,
|
||||
cage_rotation: Any,
|
||||
orient: Any,
|
||||
w: float,
|
||||
h: float,
|
||||
) -> Any:
|
||||
"""Compose the 5-term ``matrix_basis`` for a face-plane gizmo.
|
||||
|
||||
Returns ``Translation @ mw_rot_scale @ cage_rotation @ orient @
|
||||
Diagonal((w, h, 1, 1))`` — maps a unit-square local quad onto the
|
||||
world-space face rectangle, including the host's scale.
|
||||
"""
|
||||
quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0))
|
||||
return Matrix.Translation(face_world) @ mw_rot_scale.to_4x4() @ cage_rotation @ orient @ quad_scale
|
||||
|
||||
|
||||
def _compute_box_corners_world(
|
||||
bmin: Any,
|
||||
bmax: Any,
|
||||
pivot_local: Any,
|
||||
cage_rotation_3x3: Any,
|
||||
mw: Any,
|
||||
) -> dict[tuple[int, int, int], Any]:
|
||||
"""Return the 8 OBB corners in world space, keyed by bit-triple."""
|
||||
corners: dict[tuple[int, int, int], Any] = {}
|
||||
for ix in (0, 1):
|
||||
for iy in (0, 1):
|
||||
for iz in (0, 1):
|
||||
local = Vector(
|
||||
(
|
||||
float(bmax.x if ix else bmin.x),
|
||||
float(bmax.y if iy else bmin.y),
|
||||
float(bmax.z if iz else bmin.z),
|
||||
)
|
||||
)
|
||||
rotated = cage_rotation_3x3 @ (local - pivot_local) + pivot_local
|
||||
corners[(ix, iy, iz)] = mw @ rotated
|
||||
return corners
|
||||
|
||||
|
||||
def _abs_scale_matrix(mw: Any) -> Any:
|
||||
"""Return a copy of ``mw`` with all scale components ``abs()``-ed.
|
||||
|
||||
Without this, a negative-scale host produces a visible/clickable
|
||||
face inversion: ``mw @ local_vec`` flips the +axis face onto the
|
||||
-axis world side, while the rotation-only normal stays pointing
|
||||
in the +axis direction — so the gizmo for "the +X face" sits at
|
||||
world -X but reports its outward normal as +X.
|
||||
"""
|
||||
loc, rot, scale = mw.decompose()
|
||||
abs_scale = Vector((abs(scale.x), abs(scale.y), abs(scale.z)))
|
||||
return Matrix.LocRotScale(loc, rot, abs_scale)
|
||||
|
||||
|
||||
def _world_radius_to_screen_pixels(
|
||||
region: Any,
|
||||
rv3d: Any,
|
||||
center_world: Vector,
|
||||
world_radius: float,
|
||||
*,
|
||||
min_pixels: float = 0.0,
|
||||
) -> float:
|
||||
"""Return the on-screen pixel radius of a world-space circle.
|
||||
|
||||
Projects ``center_world`` and a sample point offset by
|
||||
``world_radius`` along the camera's view-aligned right axis to
|
||||
region pixels, and returns the screen-pixel distance between them.
|
||||
Falls back to ``min_pixels`` if either projection fails.
|
||||
"""
|
||||
try:
|
||||
view_inv = rv3d.view_matrix.inverted()
|
||||
right = Vector((view_inv[0][0], view_inv[0][1], view_inv[0][2])).normalized()
|
||||
except (AttributeError, ValueError):
|
||||
right = Vector((1.0, 0.0, 0.0))
|
||||
sample_world = center_world + right * world_radius
|
||||
return _world_segment_to_screen_pixels(region, rv3d, center_world, sample_world, min_pixels=min_pixels)
|
||||
|
||||
|
||||
def _world_segment_to_screen_pixels(
|
||||
region: Any,
|
||||
rv3d: Any,
|
||||
p0_world: Vector,
|
||||
p1_world: Vector,
|
||||
*,
|
||||
min_pixels: float = 0.0,
|
||||
) -> float:
|
||||
"""Return the on-screen pixel length of an arbitrary world segment.
|
||||
|
||||
Unlike :func:`_world_radius_to_screen_pixels`, this measures the
|
||||
ACTUAL projected length of the segment — foreshortening included.
|
||||
Use this when the segment direction is known to be oblique to the
|
||||
screen plane (e.g. a back face's outward normal): a perpendicular
|
||||
radius measurement overestimates the on-screen length, leaving
|
||||
halo strips visually narrower than the requested pixel target.
|
||||
"""
|
||||
p0 = location_3d_to_region_2d(region, rv3d, p0_world)
|
||||
p1 = location_3d_to_region_2d(region, rv3d, p1_world)
|
||||
if not p0 or not p1:
|
||||
return min_pixels
|
||||
dx = float(p1[0]) - float(p0[0])
|
||||
dy = float(p1[1]) - float(p0[1])
|
||||
return max(min_pixels, (dx * dx + dy * dy) ** 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gizmo classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
|
||||
"""Near-invisible face-quad click target with drag-to-resize modal.
|
||||
|
||||
Geometry: a unit quad in the local XY plane at z=0. The adapter
|
||||
group's layout helper rotates and scales it onto the face plane;
|
||||
the quad is welded to the world face (``use_draw_scale = False``).
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_box_face_quad"
|
||||
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
|
||||
|
||||
__slots__ = (
|
||||
"custom_shape",
|
||||
"custom_shape_select",
|
||||
"init_value",
|
||||
"move_get_cb",
|
||||
"move_set_cb",
|
||||
"axis",
|
||||
"start_location",
|
||||
"depth_point",
|
||||
"callback",
|
||||
"ctrl_click_cb",
|
||||
"_group",
|
||||
"_face_axis",
|
||||
"is_max",
|
||||
"_drag_snapshot",
|
||||
"_last_geometry_state",
|
||||
"_strips_cache_key",
|
||||
)
|
||||
|
||||
def draw(self, context: Any) -> None:
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
def draw_select(self, context: Any, select_id: int) -> None:
|
||||
# Back-facing quads bind ``custom_shape_select`` to the halo-strip
|
||||
# TRIS so clicks OUTSIDE the box silhouette catch the back face.
|
||||
# Front-facing quads leave it None and reuse ``custom_shape``.
|
||||
shape = getattr(self, "custom_shape_select", None) or self.custom_shape
|
||||
self.draw_custom_shape(shape, select_id=select_id)
|
||||
|
||||
def setup(self) -> None:
|
||||
if not hasattr(self, "custom_shape_"):
|
||||
self.custom_shape = self.new_custom_shape("TRIS", _QUAD_TRIS)
|
||||
self.custom_shape_select = None
|
||||
# Quad welded to world geometry — clicks must align with the
|
||||
# visible face, not a screen-size widget. Disables Blender's
|
||||
# per-frame pixel-constant autoscale.
|
||||
self.use_draw_scale = False
|
||||
|
||||
# ---- modal -------------------------------------------------------------
|
||||
|
||||
def invoke(self, context: Any, event: Any) -> set[str]:
|
||||
# CTRL+click handoff: dispatch a host-defined callback (e.g.
|
||||
# align-view) instead of starting a drag.
|
||||
if event.ctrl and getattr(self, "ctrl_click_cb", None) is not None:
|
||||
self.ctrl_click_cb(context, event)
|
||||
return {"FINISHED"}
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
if region is None or rv3d is None:
|
||||
return {"CANCELLED"}
|
||||
self.init_value = self.move_get_cb()
|
||||
# Freeze the projection plane at invoke — projection-plane
|
||||
# drift on tilted axes causes exponential delta runaway.
|
||||
self.depth_point = self.matrix_basis.translation.copy()
|
||||
self.start_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
|
||||
|
||||
if getattr(self, "_group", None) is not None:
|
||||
self._group._lock_for(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def exit(self, context: Any, cancel: bool) -> None:
|
||||
try:
|
||||
if context.area:
|
||||
context.area.header_text_set(None)
|
||||
if cancel:
|
||||
self.move_set_cb(self.init_value)
|
||||
if hasattr(self, "callback"):
|
||||
self.callback(self.move_get_cb())
|
||||
finally:
|
||||
self._drag_snapshot = None
|
||||
if getattr(self, "_group", None) is not None:
|
||||
self._group._unlock_all()
|
||||
|
||||
def modal(self, context: Any, event: Any, tweak: set[str]) -> set[str]:
|
||||
if event.type == "ESC":
|
||||
return {"CANCELLED"}
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
if region is None or rv3d is None:
|
||||
return {"CANCELLED"}
|
||||
end_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
|
||||
delta = (end_location - self.start_location).dot(self.axis)
|
||||
if "SNAP" in tweak:
|
||||
delta = round(delta, 1)
|
||||
if "PRECISE" in tweak:
|
||||
delta /= 10.0
|
||||
self.move_set_cb(self.init_value + delta)
|
||||
if context.area:
|
||||
context.area.header_text_set(f"Value: {self.move_get_cb():.3f} ({delta:.3f})")
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
|
||||
"""Thin non-interactive colored edge outline for one face.
|
||||
|
||||
Drawn as 4 line segments in the face plane. The layout helper
|
||||
toggles its ``alpha`` between near-zero and ``1.0`` based on the
|
||||
sibling face-quad's ``is_highlight`` state — so hovering the quad
|
||||
lights up the matching outline. ``hide_select = True`` keeps the
|
||||
outline out of the GPU selection buffer.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_box_face_outline"
|
||||
bl_target_properties = ()
|
||||
|
||||
__slots__ = (
|
||||
"custom_shape",
|
||||
"_face_axis",
|
||||
"is_max",
|
||||
"_last_outline_state",
|
||||
)
|
||||
|
||||
def draw(self, context: Any) -> None:
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
def draw_select(self, context: Any, select_id: int) -> None:
|
||||
return None
|
||||
|
||||
def setup(self) -> None:
|
||||
if not hasattr(self, "custom_shape_"):
|
||||
self.custom_shape = self.new_custom_shape("LINES", _QUAD_OUTLINE_LINES)
|
||||
self.use_draw_scale = False
|
||||
self.hide_select = True
|
||||
self._last_outline_state = "unit"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-redraw orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_face_quad_layout(
|
||||
*,
|
||||
quad_gizmos,
|
||||
outline_gizmos,
|
||||
bmin: Any,
|
||||
bmax: Any,
|
||||
matrix_world: Any,
|
||||
cage_rotation: Any,
|
||||
region: Any,
|
||||
rv3d: Any,
|
||||
locked: bool,
|
||||
) -> None:
|
||||
"""Lay out 6 face quads + 6 outlines on the box for this redraw.
|
||||
|
||||
``quad_gizmos`` / ``outline_gizmos`` are length-6 sequences in
|
||||
:data:`FACE_ROUTES` order. ``bmin`` / ``bmax`` are the box corners
|
||||
in the host's local frame; ``matrix_world`` is the host's world
|
||||
matrix; ``cage_rotation`` is the OBB rotation as a 4x4 (use
|
||||
``Matrix.Identity(4)`` when rotation rides in ``matrix_world``).
|
||||
``region`` / ``rv3d`` drive the view-dependent front/back split and
|
||||
the screen-constant halo margin; passing ``rv3d = None`` bails.
|
||||
|
||||
Negative scale on the host is normalized to positive internally so
|
||||
the visible cube and the clickable face gizmos stay aligned —
|
||||
callers don't need to pre-process ``matrix_world``.
|
||||
|
||||
When ``locked`` (a drag is active), ``hide`` / ``select_bias``
|
||||
writes are skipped — the active quad's geometry is still refreshed
|
||||
so it tracks the moving box.
|
||||
"""
|
||||
if rv3d is None or getattr(rv3d, "view_rotation", None) is None:
|
||||
return
|
||||
if len(quad_gizmos) != 6 or len(outline_gizmos) != 6:
|
||||
return
|
||||
|
||||
mw = _abs_scale_matrix(matrix_world)
|
||||
mw_rot = mw.to_quaternion().to_matrix()
|
||||
mw_rot_scale = mw.to_3x3()
|
||||
cage_rotation_3x3 = cage_rotation.to_3x3()
|
||||
pivot_local = (bmin + bmax) * 0.5
|
||||
box_center_local = pivot_local
|
||||
face_midpoints_local = {
|
||||
(0, False): Vector((float(bmin.x), box_center_local.y, box_center_local.z)),
|
||||
(0, True): Vector((float(bmax.x), box_center_local.y, box_center_local.z)),
|
||||
(1, False): Vector((box_center_local.x, float(bmin.y), box_center_local.z)),
|
||||
(1, True): Vector((box_center_local.x, float(bmax.y), box_center_local.z)),
|
||||
(2, False): Vector((box_center_local.x, box_center_local.y, float(bmin.z))),
|
||||
(2, True): Vector((box_center_local.x, box_center_local.y, float(bmax.z))),
|
||||
}
|
||||
|
||||
view_dir = (rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))).normalized()
|
||||
view_dir_tuple = (float(view_dir.x), float(view_dir.y), float(view_dir.z))
|
||||
face_normals_world = []
|
||||
for route_axis, route_is_max in FACE_ROUTES:
|
||||
axis_local = Vector(face_outward_axis_local(route_axis, route_is_max))
|
||||
n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized()
|
||||
face_normals_world.append((float(n_world.x), float(n_world.y), float(n_world.z)))
|
||||
front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple)
|
||||
|
||||
box_center_world = mw @ pivot_local
|
||||
corners_world = _compute_box_corners_world(bmin, bmax, pivot_local, cage_rotation_3x3, mw)
|
||||
route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)}
|
||||
|
||||
for i, route in enumerate(FACE_ROUTES):
|
||||
quad_gz = quad_gizmos[i]
|
||||
is_front = front[i]
|
||||
axis_b, is_max_b = route
|
||||
|
||||
# Place the colored OUTLINE on every face using the same composed
|
||||
# face matrix the front-facing solid quad uses. Hidden/shown via
|
||||
# alpha at the end of the pass.
|
||||
outline_orient = _AXIS_ORIENT[route]
|
||||
outline_face_world, _outline_axis = _compute_face_basis(
|
||||
mw,
|
||||
mw_rot,
|
||||
cage_rotation,
|
||||
pivot_local,
|
||||
face_midpoints_local[route],
|
||||
outline_orient,
|
||||
)
|
||||
ow, oh = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
|
||||
outline_gizmos[i].matrix_basis = _compose_face_matrix_basis(
|
||||
outline_face_world, mw_rot_scale, cage_rotation, outline_orient, ow, oh
|
||||
)
|
||||
|
||||
if is_front:
|
||||
if not locked:
|
||||
quad_gz.hide = False
|
||||
quad_gz.select_bias = _FACE_QUAD_FRONT_FACING_SELECT_BIAS
|
||||
orient = _AXIS_ORIENT[route]
|
||||
face_world, world_axis = _compute_face_basis(
|
||||
mw,
|
||||
mw_rot,
|
||||
cage_rotation,
|
||||
pivot_local,
|
||||
face_midpoints_local[route],
|
||||
orient,
|
||||
)
|
||||
w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
|
||||
quad_gz.matrix_basis = _compose_face_matrix_basis(face_world, mw_rot_scale, cage_rotation, orient, w, h)
|
||||
quad_gz.axis = world_axis
|
||||
if getattr(quad_gz, "_last_geometry_state", None) != "solid":
|
||||
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS)
|
||||
quad_gz.custom_shape_select = None
|
||||
quad_gz._last_geometry_state = "solid"
|
||||
continue
|
||||
|
||||
# Back-facing: anchor at the back face centre; build halo strips
|
||||
# in the planes of the adjacent FRONT faces, extruded outside
|
||||
# the silhouette toward this face's outward normal.
|
||||
face_world = mw @ (cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + pivot_local)
|
||||
quad_gz.matrix_basis = Matrix.Translation(face_world)
|
||||
quad_gz.axis = (mw_rot @ cage_rotation_3x3 @ Vector(face_outward_axis_local(axis_b, is_max_b))).normalized()
|
||||
|
||||
adjacent_front_routes = [
|
||||
(axis_a, is_max_a)
|
||||
for axis_a in range(3)
|
||||
if axis_a != axis_b
|
||||
for is_max_a in (False, True)
|
||||
if front[route_to_index[(axis_a, is_max_a)]]
|
||||
]
|
||||
# Per-face world margin: measure the screen-projected length of
|
||||
# ONE world unit along THIS face's outward normal. The world
|
||||
# margin that yields ~N pixels on screen is then ``N / length``.
|
||||
# Foreshortening on oblique faces shortens the projected step,
|
||||
# so the world step must grow to keep the strip the same width
|
||||
# on screen.
|
||||
face_world_margin = 0.0
|
||||
if region is not None:
|
||||
sample_end = box_center_world + quad_gz.axis * 1.0
|
||||
screen_step = _world_segment_to_screen_pixels(region, rv3d, box_center_world, sample_end, min_pixels=0.0)
|
||||
if screen_step > 0.0:
|
||||
face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step
|
||||
if face_world_margin <= 0.0 or not adjacent_front_routes:
|
||||
if not locked:
|
||||
quad_gz.hide = True
|
||||
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
|
||||
if getattr(quad_gz, "_last_geometry_state", None) != "empty":
|
||||
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _EMPTY_TRIS)
|
||||
quad_gz.custom_shape_select = None
|
||||
quad_gz._last_geometry_state = "empty"
|
||||
continue
|
||||
|
||||
extrusion_world = quad_gz.axis * face_world_margin
|
||||
extrusion_local = (
|
||||
float(extrusion_world.x),
|
||||
float(extrusion_world.y),
|
||||
float(extrusion_world.z),
|
||||
)
|
||||
all_tris: list[tuple[float, float, float]] = []
|
||||
for axis_a, is_max_a in adjacent_front_routes:
|
||||
edge_keys = _shared_edge_corner_keys(axis_a, is_max_a, axis_b, is_max_b)
|
||||
if edge_keys is None:
|
||||
continue
|
||||
key0, key1 = edge_keys
|
||||
wp0 = corners_world[key0]
|
||||
wp1 = corners_world[key1]
|
||||
local_p0 = (
|
||||
float(wp0.x - face_world.x),
|
||||
float(wp0.y - face_world.y),
|
||||
float(wp0.z - face_world.z),
|
||||
)
|
||||
local_p1 = (
|
||||
float(wp1.x - face_world.x),
|
||||
float(wp1.y - face_world.y),
|
||||
float(wp1.z - face_world.z),
|
||||
)
|
||||
all_tris.extend(_build_strip_tris_relative(local_p0, local_p1, extrusion_local))
|
||||
|
||||
if not locked:
|
||||
quad_gz.hide = False
|
||||
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
|
||||
|
||||
corner_keys = _face_corner_keys(axis_b, is_max_b)
|
||||
wc_local = [
|
||||
(
|
||||
float(corners_world[k].x - face_world.x),
|
||||
float(corners_world[k].y - face_world.y),
|
||||
float(corners_world[k].z - face_world.z),
|
||||
)
|
||||
for k in corner_keys
|
||||
]
|
||||
face_quad_local = [
|
||||
wc_local[0],
|
||||
wc_local[1],
|
||||
wc_local[2],
|
||||
wc_local[0],
|
||||
wc_local[2],
|
||||
wc_local[3],
|
||||
]
|
||||
if _strips_geometry_changed(quad_gz, tuple(face_quad_local), tuple(all_tris)):
|
||||
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", face_quad_local)
|
||||
quad_gz.custom_shape_select = quad_gz.new_custom_shape("TRIS", all_tris)
|
||||
quad_gz._last_geometry_state = "strips"
|
||||
|
||||
# Outline alpha follows ONLY the hovered quad's own state — light
|
||||
# the outline of the face under the cursor, nothing else.
|
||||
if not locked:
|
||||
for outline_gz, quad_gz in zip(outline_gizmos, quad_gizmos, strict=True):
|
||||
lit = bool(getattr(quad_gz, "is_highlight", False))
|
||||
outline_gz.alpha = 1.0 if lit else 0.0
|
||||
outline_gz.alpha_highlight = 1.0 if lit else 0.0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AXIS_COLOR",
|
||||
"FACE_QUAD_ALPHA",
|
||||
"FACE_QUAD_ALPHA_HIGHLIGHT",
|
||||
"FACE_QUAD_SELECT_BIAS",
|
||||
"FACE_ROUTES",
|
||||
"BIM_GT_box_face_outline",
|
||||
"BIM_GT_box_face_quad",
|
||||
"apply_face_quad_layout",
|
||||
"compute_face_resize",
|
||||
"face_outward_axis_local",
|
||||
"front_facing_face_mask",
|
||||
"view_axis_parallel_face_mask",
|
||||
]
|
||||
@@ -0,0 +1,312 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Interactive face-quad resize gizmos for the active clip box.
|
||||
|
||||
Adapter group that binds the generic :mod:`face_quad` core to a Bonsai
|
||||
clip-box Empty: six near-invisible click quads + six edge outlines on
|
||||
the cube's faces. Dragging a face does a ONE-SIDED resize — the dragged
|
||||
face moves along its outward world normal while the opposite face stays
|
||||
put — by writing the empty's ``location`` and ``scale``. Bonsai's
|
||||
depsgraph handler then re-arms the clip planes from the new matrix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import face_quad
|
||||
|
||||
# Local-frame bounds of the empty's CUBE display. The display spans
|
||||
# ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets
|
||||
# ``empty_display_size = 1.0`` on clip-box hosts, so the local box is
|
||||
# the unit cube. The empty's per-axis scale + rotation + translation
|
||||
# ride in ``matrix_world``, which the layout helper applies.
|
||||
_LOCAL_BMIN = Vector((-1.0, -1.0, -1.0))
|
||||
_LOCAL_BMAX = Vector((1.0, 1.0, 1.0))
|
||||
|
||||
|
||||
def _world_axis(empty: bpy.types.Object, axis: int, is_max: bool) -> Vector:
|
||||
"""Outward world-space unit normal of the ``(axis, is_max)`` face.
|
||||
|
||||
Uses the rotation-only matrix so a negative-scale empty doesn't
|
||||
flip the resulting direction — the visible "+X face" then stays
|
||||
associated with world +X (transformed through rotation).
|
||||
"""
|
||||
rot_mat = empty.matrix_world.to_quaternion().to_matrix()
|
||||
n = Vector(rot_mat.col[axis])
|
||||
if n.length <= 0.0:
|
||||
return Vector((0.0, 0.0, 0.0))
|
||||
n.normalize()
|
||||
return n if is_max else -n
|
||||
|
||||
|
||||
def _world_half_extent(empty: bpy.types.Object, axis: int) -> float:
|
||||
"""The empty's box half-extent along local ``axis`` in WORLD units.
|
||||
|
||||
A CUBE empty's local cube is ``±empty_display_size``; ``matrix_world``
|
||||
stretches it by the column length on ``axis``. So the world
|
||||
half-extent is ``|column[axis]| * empty_display_size``.
|
||||
"""
|
||||
col_len = empty.matrix_world.to_3x3().col[axis].length
|
||||
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
|
||||
return float(col_len) * display_size
|
||||
|
||||
|
||||
def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool):
|
||||
"""Closure returning the world half-extent at drag start and
|
||||
snapshotting the empty's full transform on the gizmo instance.
|
||||
|
||||
The snapshot lives on the gizmo (not the group) so a PERSISTENT
|
||||
group servicing multiple clip boxes can't bleed one drag's state
|
||||
onto another. Cleared on ``exit`` by the shared face-quad hook.
|
||||
"""
|
||||
|
||||
def getter() -> float:
|
||||
empty = group._empty
|
||||
if empty is None:
|
||||
return 0.0
|
||||
existing = getattr(gz, "_drag_snapshot", None)
|
||||
if existing is not None and existing.get("empty_name") == getattr(empty, "name", None):
|
||||
return float(existing["world_half"])
|
||||
|
||||
world_half = _world_half_extent(empty, axis)
|
||||
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
|
||||
gz._drag_snapshot = {
|
||||
"empty_name": getattr(empty, "name", None),
|
||||
"world_half": world_half,
|
||||
"location": tuple(float(v) for v in empty.location),
|
||||
"scale": tuple(float(v) for v in empty.scale),
|
||||
"display_size": display_size if display_size != 0.0 else 1.0,
|
||||
"world_axis": tuple(_world_axis(empty, axis, is_max)),
|
||||
}
|
||||
return float(world_half)
|
||||
|
||||
return getter
|
||||
|
||||
|
||||
def _make_ctrl_click_cb(axis: int, is_max: bool):
|
||||
"""Closure that dispatches CTRL+click on a face to the align-view operator.
|
||||
|
||||
Routing through an operator (rather than mutating ``rv3d`` here)
|
||||
keeps the action F3-searchable and undoable.
|
||||
"""
|
||||
|
||||
def _callback(_context: Any, _event: Any) -> None:
|
||||
bpy.ops.bim.align_view_to_clip_face("INVOKE_DEFAULT", axis=axis, is_max=is_max)
|
||||
|
||||
return _callback
|
||||
|
||||
|
||||
def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool):
|
||||
"""Closure that applies a one-sided face resize by writing the
|
||||
empty's ``location`` + ``scale``.
|
||||
|
||||
The modal calls this with ``value = init + delta`` where ``delta``
|
||||
is the cursor's projection onto the face's OUTWARD world normal.
|
||||
Both reads come from ``gz._drag_snapshot`` so every frame is
|
||||
relative to drag start, never compounding.
|
||||
"""
|
||||
del is_max # snapshot's world_axis carries the direction
|
||||
|
||||
def setter(value: float) -> None:
|
||||
empty = group._empty
|
||||
if empty is None:
|
||||
return
|
||||
snap = getattr(gz, "_drag_snapshot", None)
|
||||
if snap is None or snap.get("empty_name") != getattr(empty, "name", None):
|
||||
return
|
||||
|
||||
new_scale_axis, new_location = face_quad.compute_face_resize(
|
||||
value=value,
|
||||
init_world_half=snap["world_half"],
|
||||
init_location=snap["location"],
|
||||
world_axis=snap["world_axis"],
|
||||
display_size=snap["display_size"],
|
||||
)
|
||||
new_scale = list(snap["scale"])
|
||||
# Preserve the sign of the original scale so a user-flipped empty
|
||||
# stays flipped after the resize — compute_face_resize returns a
|
||||
# positive magnitude, the sign is the user's intent to keep.
|
||||
sign = -1.0 if snap["scale"][axis] < 0.0 else 1.0
|
||||
new_scale[axis] = sign * new_scale_axis
|
||||
|
||||
empty.scale = new_scale
|
||||
empty.location = Vector(new_location)
|
||||
|
||||
return setter
|
||||
|
||||
|
||||
class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention
|
||||
"""Face-quad resize handles on the active clip box.
|
||||
|
||||
Renders six near-invisible click-target quads and six colored edge
|
||||
outlines on the active clip-box empty whenever clipping is enabled.
|
||||
Click-and-drag a face to resize one-sided; the opposite face stays
|
||||
put. CTRL+click and plain click fall through to selection.
|
||||
"""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_clip_box"
|
||||
bl_label = "Bonsai Clip Box Faces"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Any) -> bool:
|
||||
scene = getattr(context, "scene", None)
|
||||
if scene is None:
|
||||
return False
|
||||
scene_props = tool.ClipBox.get_scene_props(scene)
|
||||
if not scene_props.enabled or not scene_props.enable_gizmos:
|
||||
return False
|
||||
active_clip_box = tool.ClipBox.get_active_clip_box(scene)
|
||||
if active_clip_box is None:
|
||||
return False
|
||||
# Only render when the user has the active clip box itself
|
||||
# selected — otherwise the face handles would intercept clicks
|
||||
# meant for the geometry behind them.
|
||||
return getattr(context, "active_object", None) is active_clip_box
|
||||
|
||||
@classmethod
|
||||
def setup_keymap(cls, keyconfig):
|
||||
# Bind CLICK_DRAG so plain LEFTMOUSE PRESS passes through to
|
||||
# selection — the user can still click through a near-invisible
|
||||
# face quad to pick a mesh behind it.
|
||||
km = keyconfig.keymaps.new(
|
||||
name=cls.bl_idname,
|
||||
space_type=cls.bl_space_type,
|
||||
region_type=cls.bl_region_type,
|
||||
)
|
||||
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG")
|
||||
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True)
|
||||
return km
|
||||
|
||||
def setup(self, context: Any) -> None:
|
||||
# ``_empty`` is resolved each refresh so the PERSISTENT group
|
||||
# follows whichever clip box is active in the scene PG.
|
||||
self._empty: bpy.types.Object | None = None
|
||||
self._locked = False
|
||||
self._face_routes: list[tuple[int, bool]] = []
|
||||
|
||||
for axis, is_max in face_quad.FACE_ROUTES:
|
||||
gz = self.gizmos.new(face_quad.BIM_GT_box_face_quad.bl_idname)
|
||||
gz._group = self
|
||||
gz._face_axis = axis
|
||||
gz.is_max = is_max
|
||||
gz._drag_snapshot = None
|
||||
gz._last_geometry_state = "solid"
|
||||
gz._strips_cache_key = None
|
||||
gz.color = face_quad.AXIS_COLOR[axis]
|
||||
gz.color_highlight = tuple(min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis])
|
||||
gz.alpha = face_quad.FACE_QUAD_ALPHA
|
||||
gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT
|
||||
gz.use_draw_modal = True
|
||||
gz.scale_basis = 1.0
|
||||
gz.select_bias = face_quad.FACE_QUAD_SELECT_BIAS
|
||||
gz.move_get_cb = _make_face_get_cb(gz, self, axis, is_max)
|
||||
gz.move_set_cb = _make_face_set_cb(gz, self, axis, is_max)
|
||||
# CTRL+click on a face aligns the viewport to look at it.
|
||||
gz.ctrl_click_cb = _make_ctrl_click_cb(axis, is_max)
|
||||
self._face_routes.append((axis, is_max))
|
||||
|
||||
# Outlines added last so they composite on top of the quad
|
||||
# fills (Blender draws gizmos in creation order).
|
||||
for axis, is_max in face_quad.FACE_ROUTES:
|
||||
ol = self.gizmos.new(face_quad.BIM_GT_box_face_outline.bl_idname)
|
||||
ol._face_axis = axis
|
||||
ol.is_max = is_max
|
||||
ol.color = face_quad.AXIS_COLOR[axis]
|
||||
ol.color_highlight = face_quad.AXIS_COLOR[axis]
|
||||
ol.alpha = 0.0
|
||||
ol.alpha_highlight = 0.0
|
||||
ol.line_width = 2.5
|
||||
|
||||
def _quad_gizmos(self):
|
||||
return self.gizmos[: len(self._face_routes)]
|
||||
|
||||
def _outline_gizmos(self):
|
||||
n = len(self._face_routes)
|
||||
return self.gizmos[n : 2 * n]
|
||||
|
||||
def refresh(self, context: Any) -> None:
|
||||
"""State-change path: resolve the active empty, then run the
|
||||
shared face-quad layout so the quads aren't stale for a frame
|
||||
after a selection or active-index change."""
|
||||
empty = tool.ClipBox.get_active_clip_box(context.scene)
|
||||
self._empty = empty
|
||||
if empty is None:
|
||||
for gz in self.gizmos:
|
||||
gz.hide = True
|
||||
return
|
||||
self._layout(context, empty)
|
||||
|
||||
def draw_prepare(self, context: Any) -> None:
|
||||
"""Per-redraw — fires on orbit — re-run the layout so the
|
||||
front/back split, halo strips, and outline highlights track
|
||||
the camera and any live G/R/S on the empty."""
|
||||
empty = self._empty
|
||||
if empty is None:
|
||||
return
|
||||
self._layout(context, empty)
|
||||
|
||||
def _layout(self, context: Any, empty: bpy.types.Object) -> None:
|
||||
face_quad.apply_face_quad_layout(
|
||||
quad_gizmos=self._quad_gizmos(),
|
||||
outline_gizmos=self._outline_gizmos(),
|
||||
bmin=_LOCAL_BMIN,
|
||||
bmax=_LOCAL_BMAX,
|
||||
matrix_world=empty.matrix_world,
|
||||
# The empty's rotation rides in matrix_world, so the
|
||||
# box-local OBB rotation is identity.
|
||||
cage_rotation=Matrix.Identity(4),
|
||||
region=getattr(context, "region", None),
|
||||
rv3d=getattr(context, "region_data", None),
|
||||
locked=self._locked,
|
||||
)
|
||||
|
||||
# ---- mutual exclusion (lock siblings during a drag) ------------------
|
||||
|
||||
def _lock_for(self, active_gizmo) -> None:
|
||||
self._locked = True
|
||||
for gz in self.gizmos:
|
||||
if gz is not active_gizmo:
|
||||
with contextlib.suppress(ReferenceError, RuntimeError):
|
||||
gz.hide = True
|
||||
|
||||
def _unlock_all(self) -> None:
|
||||
self._locked = False
|
||||
for gz in self.gizmos:
|
||||
with contextlib.suppress(ReferenceError, RuntimeError):
|
||||
gz.hide = False
|
||||
# Rebuild caps synchronously so the cross-section overlay
|
||||
# re-forms the instant the user releases the handle, rather
|
||||
# than waiting for the depsgraph's debounced rebuild path.
|
||||
with contextlib.suppress(RuntimeError, ReferenceError):
|
||||
tool.ClipBox.rebuild_caps_now()
|
||||
# Push an undo step so the user can revert a face drag with Ctrl+Z.
|
||||
with contextlib.suppress(RuntimeError):
|
||||
bpy.ops.ed.undo_push(message="Resize Clip Box")
|
||||
@@ -0,0 +1,294 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
|
||||
from . import data
|
||||
|
||||
# NOTE: do NOT add ``from __future__ import annotations`` to this module.
|
||||
# PEP 563 stringifies the operator's EnumProperty class annotations, which
|
||||
# breaks any introspection that reads ``cls.__annotations__[name].keywords``
|
||||
# — including the enum-search helper that draws the search-button icon.
|
||||
|
||||
CLIP_BOX_NAME = "ClipBox"
|
||||
|
||||
# Display labels for the source-based picker, used for the menu entries and
|
||||
# the dialog title. The dict keys are the canonical source-kind identifiers.
|
||||
SOURCE_KIND_LABELS: dict[str, str] = {
|
||||
"SPATIAL": "Spatial Element",
|
||||
"CLASS": "Class",
|
||||
"TYPE": "Type",
|
||||
"MATERIAL": "Material",
|
||||
"PROFILE": "Profile",
|
||||
"DRAWING": "Drawing",
|
||||
"STATUS": "Status",
|
||||
"SYSTEM": "System",
|
||||
"GROUP": "Group",
|
||||
"ZONE": "Zone",
|
||||
}
|
||||
|
||||
|
||||
_SOURCE_ID_DISPATCH = {
|
||||
"SPATIAL": data.spatial_items,
|
||||
"CLASS": data.class_items,
|
||||
"TYPE": data.type_items,
|
||||
"MATERIAL": data.material_items,
|
||||
"PROFILE": data.profile_items,
|
||||
"DRAWING": data.drawing_items,
|
||||
"STATUS": data.status_items,
|
||||
"SYSTEM": data.system_items,
|
||||
"GROUP": data.group_items,
|
||||
"ZONE": data.zone_items,
|
||||
}
|
||||
|
||||
|
||||
def _source_id_items(self, context):
|
||||
"""Dispatch the ``source_id`` enum items based on the picked ``source_kind``."""
|
||||
fn = _SOURCE_ID_DISPATCH.get(self.source_kind)
|
||||
if fn is None:
|
||||
return [(data.NO_OPTIONS_ID, "No options", "")]
|
||||
return fn(self, context)
|
||||
|
||||
|
||||
def _source_display_name(kind, source_id):
|
||||
"""Human-readable name of the picked source, used in the clip-box name."""
|
||||
if kind == "STATUS":
|
||||
return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id)
|
||||
if kind == "CLASS":
|
||||
# source_id IS the human-readable IFC class name.
|
||||
return source_id
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return source_id
|
||||
try:
|
||||
entity = ifc.by_id(int(source_id))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
return source_id
|
||||
return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
|
||||
|
||||
|
||||
class BIM_OT_align_view_to_clip_face(bpy.types.Operator):
|
||||
bl_idname = "bim.align_view_to_clip_face"
|
||||
bl_label = "Align View to Clip Box Face"
|
||||
bl_description = "Orient the 3D viewport to look directly at the picked clip-box face"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"})
|
||||
is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
|
||||
|
||||
def execute(self, context):
|
||||
rv3d = getattr(context, "region_data", None)
|
||||
if rv3d is None:
|
||||
return {"CANCELLED"}
|
||||
clip_box = tool.ClipBox.get_active_clip_box(context.scene)
|
||||
if clip_box is None:
|
||||
return {"CANCELLED"}
|
||||
rot_mat = clip_box.matrix_world.to_quaternion().to_matrix()
|
||||
outward_local = Vector((0.0, 0.0, 0.0))
|
||||
outward_local[self.axis] = 1.0 if self.is_max else -1.0
|
||||
outward = (rot_mat @ outward_local).normalized()
|
||||
if outward.length == 0.0:
|
||||
return {"CANCELLED"}
|
||||
up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized()
|
||||
rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _local_up_for_face(axis: int, is_max: bool) -> Vector:
|
||||
"""Box-local up direction for a face, following Blender numpad conventions.
|
||||
|
||||
Side faces (local ±X / ±Y normal) → local +Z is up. Top face (local +Z
|
||||
normal) → local +Y is up; bottom face (local -Z normal) → local -Y is
|
||||
up. The caller rotates this through the empty's matrix so the
|
||||
resulting world up axis tracks the box's orientation.
|
||||
"""
|
||||
if axis == 2:
|
||||
return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0))
|
||||
return Vector((0.0, 0.0, 1.0))
|
||||
|
||||
|
||||
def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion":
|
||||
"""Build a camera ``view_rotation`` that looks along ``forward`` with
|
||||
``up_hint`` projected to the camera's local +Y."""
|
||||
back = -forward.normalized()
|
||||
right = up_hint.cross(back)
|
||||
if right.length < 1e-6:
|
||||
right = Vector((1.0, 0.0, 0.0))
|
||||
right.normalize()
|
||||
up = back.cross(right).normalized()
|
||||
return Matrix(
|
||||
(
|
||||
(right.x, up.x, back.x),
|
||||
(right.y, up.y, back.y),
|
||||
(right.z, up.z, back.z),
|
||||
)
|
||||
).to_quaternion()
|
||||
|
||||
|
||||
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):
|
||||
# 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.
|
||||
matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal((10.0, 10.0, 10.0, 1.0))
|
||||
tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_add_clip_box_for_source(bpy.types.Operator):
|
||||
bl_idname = "bim.add_clip_box_for_source"
|
||||
bl_label = "Add Clip Box From Source"
|
||||
bl_description = (
|
||||
"Create a clip box sized to a chosen source: a spatial container, IFC type, material, "
|
||||
"profile, drawing camera frustum, element status, system, group, or zone"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
source_kind: bpy.props.EnumProperty(
|
||||
name="Source Kind",
|
||||
items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()],
|
||||
default="SPATIAL",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
source_id: bpy.props.EnumProperty(
|
||||
name="Source",
|
||||
items=_source_id_items,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}"
|
||||
# Search button appears once the enum exceeds the helper's threshold,
|
||||
# giving the user a popup picker instead of a plain dropdown.
|
||||
prop_with_search(layout, self, "source_id", text=label)
|
||||
|
||||
def execute(self, context):
|
||||
if not self.source_id or self.source_id == data.NO_OPTIONS_ID:
|
||||
self.report({"ERROR"}, "No source selected.")
|
||||
return {"CANCELLED"}
|
||||
matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id)
|
||||
if matrix is None:
|
||||
kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}"
|
||||
tool.ClipBox.create_clip_box_empty(context, matrix, name=name)
|
||||
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 = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name)
|
||||
# Preserve the source's display attrs so the duplicate matches.
|
||||
copy.empty_display_type = source.empty_display_type
|
||||
copy.empty_display_size = source.empty_display_size
|
||||
copy.show_in_front = source.show_in_front
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,165 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
from bpy.types import PropertyGroup
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.prop import ObjProperty
|
||||
|
||||
|
||||
class BIMClipBoxProperties(PropertyGroup):
|
||||
"""Per-object marker for a clip-box host empty.
|
||||
|
||||
The host empty's ``matrix_world`` is the single source of truth for
|
||||
the clip box's pose and dimensions: translation = box centre,
|
||||
rotation = box orientation, per-axis scale = world half-extents. The
|
||||
visible cube comes from the empty's CUBE display.
|
||||
|
||||
Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay
|
||||
(``show_caps``) are global per-file and live on the Scene PG.
|
||||
"""
|
||||
|
||||
is_clip_box: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True when this empty was created as a clip-box host. Internal flag; not user-edited.",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_clip_box: bool
|
||||
|
||||
|
||||
def update_active_clip_box_index(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
tool.ClipBox.select_active_clip_box(context)
|
||||
# Rebuild caps for the new active box's clip volume.
|
||||
tool.ClipBox.invalidate_cap_cache(immediate=True)
|
||||
|
||||
|
||||
def update_show_caps(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
# Off → on must trigger a rebuild so caps reappear immediately rather
|
||||
# than wait for the next depsgraph tick. The rebuild is a no-op when
|
||||
# show_caps is now False (it clears and returns), so this is safe in
|
||||
# both directions.
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
def update_enabled(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
|
||||
|
||||
def update_clip_only_ifc_products(self, context):
|
||||
# The eligibility set for capping changed — drop the cache and let the
|
||||
# debounced rebuild pick up the new objects on the next idle tick.
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
def update_include_linked_ifc(self, context):
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
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"
|
||||
),
|
||||
)
|
||||
# Stored on the Scene PG so Blender persists it in the .blend; deliberately
|
||||
# NOT written to the project pset so the IFC stays portable across users
|
||||
# who may have different Blender-side reference geometry to clip.
|
||||
clip_only_ifc_products: bpy.props.BoolProperty(
|
||||
name="Only IFC Products",
|
||||
default=True,
|
||||
update=update_clip_only_ifc_products,
|
||||
description=(
|
||||
"When enabled, only IFC element geometry gets cross-section caps. "
|
||||
"Disable to also cap Blender-side reference meshes (sketches, "
|
||||
"imported obj, primitive cubes, …)"
|
||||
),
|
||||
)
|
||||
# Opt-in inclusion of geometry sitting inside loaded Project › Links
|
||||
# collection-instance empties. Off by default — linked IFCs commonly
|
||||
# carry the entire site / structural / MEP context, and bisecting
|
||||
# them on every clip-box edit can be expensive.
|
||||
include_linked_ifc: bpy.props.BoolProperty(
|
||||
name="Include Linked IFC",
|
||||
default=False,
|
||||
update=update_include_linked_ifc,
|
||||
description=(
|
||||
"Also generate cross-section caps for geometry inside linked "
|
||||
"IFC files (Project ▸ Links). Off by default — linked IFCs may "
|
||||
"carry the entire site / structural backbone, and capping them "
|
||||
"adds per-mesh bisect cost on every clip-box edit"
|
||||
),
|
||||
)
|
||||
# Also Scene-only — gizmo visibility is a per-user editing preference,
|
||||
# not a portable IFC property.
|
||||
enable_gizmos: bpy.props.BoolProperty(
|
||||
name="Show Face Handles",
|
||||
default=True,
|
||||
description=(
|
||||
"Show interactive face-resize handles on the active clip box. "
|
||||
"Disable to fall back to plain G/R/S transforms on the empty"
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
active_clip_box_index: int
|
||||
enabled: bool
|
||||
show_caps: bool
|
||||
clip_only_ifc_products: bool
|
||||
include_linked_ifc: bool
|
||||
enable_gizmos: bool
|
||||
@@ -0,0 +1,145 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
# Per-kind icon for the source-picker menu. Picked from Blender's built-in
|
||||
# icon set; semantically close to the kind so users can scan the menu visually.
|
||||
_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = (
|
||||
("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"),
|
||||
("CLASS", "Clip by Class", "BLANK1"),
|
||||
("TYPE", "Clip Type", "FILE_3D"),
|
||||
("MATERIAL", "Clip Material", "MATERIAL"),
|
||||
("PROFILE", "Clip Profile", "MESH_CIRCLE"),
|
||||
("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"),
|
||||
("STATUS", "Clip by Status", "INFO"),
|
||||
("SYSTEM", "Clip by System", "MOD_FLUID"),
|
||||
("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"),
|
||||
("ZONE", "Clip by Zone", "MOD_LATTICE"),
|
||||
)
|
||||
|
||||
|
||||
class BIM_MT_clip_box_add_for_source(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_add_for_source"
|
||||
bl_label = "Add Clip Box From Source"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
for kind, label, icon in _SOURCE_MENU_ENTRIES:
|
||||
op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon)
|
||||
op.source_kind = kind
|
||||
|
||||
|
||||
class BIM_MT_clip_box_settings(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_settings"
|
||||
bl_label = "Clip Box Settings"
|
||||
|
||||
def draw(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
self.layout.prop(scene_props, "clip_only_ifc_products")
|
||||
self.layout.prop(scene_props, "include_linked_ifc")
|
||||
self.layout.prop(scene_props, "enable_gizmos")
|
||||
|
||||
|
||||
class BIM_MT_clip_box_info(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_info"
|
||||
bl_label = "Clip Box Face Handles"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Face Handles", icon="INFO")
|
||||
layout.separator()
|
||||
layout.label(text="Drag a face to resize the clip box on that axis.")
|
||||
layout.label(text="The opposite face stays fixed (one-sided resize).")
|
||||
layout.label(text="Ctrl+Click a face to align the viewport to it.")
|
||||
layout.separator()
|
||||
layout.label(text="Toggle handles from the Settings (gear) menu.")
|
||||
|
||||
|
||||
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
|
||||
row = layout.row(align=True)
|
||||
if obj is None:
|
||||
# Host empty was deleted from outliner; still expose the
|
||||
# remove button so the orphan entry isn't permanent.
|
||||
row.label(text="(missing)", icon="ERROR")
|
||||
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
|
||||
return
|
||||
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)
|
||||
toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="")
|
||||
toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="")
|
||||
|
||||
layout.separator()
|
||||
row = layout.row(align=True)
|
||||
row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
|
||||
row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="")
|
||||
|
||||
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")
|
||||
@@ -159,40 +159,13 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
|
||||
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
|
||||
|
||||
|
||||
_BONSAI_TRANSFORM_MACROS = frozenset(
|
||||
{
|
||||
# Bonsai overrides Blender's default move/duplicate keymaps with
|
||||
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
|
||||
# modal entry, the inner TRANSFORM_OT_translate does not surface in
|
||||
# window.modal_operators — the macro's own idname does. The
|
||||
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
|
||||
# runtime (the class declaration uses the dotted ``bim.`` form).
|
||||
"BIM_OT_override_move_macro", # G key
|
||||
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
|
||||
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
|
||||
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_transform_modal_active(context) -> bool:
|
||||
"""True iff a Blender transform modal (G/R/S and siblings, including
|
||||
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
|
||||
updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of
|
||||
running modal operators. Parametric gizmo groups gate poll + draw_prepare
|
||||
on this so they hide for the duration of the drag instead of sliding
|
||||
off-cursor as the matrix updates each frame."""
|
||||
window = getattr(context, "window", None)
|
||||
if window is None:
|
||||
return False
|
||||
modal_ops = getattr(window, "modal_operators", None)
|
||||
if not modal_ops:
|
||||
return False
|
||||
for op in modal_ops:
|
||||
idname = op.bl_idname
|
||||
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
|
||||
return True
|
||||
return False
|
||||
"""Module-local alias for ``tool.Blender.is_transform_modal_active``.
|
||||
|
||||
Preserved as a name so AST scans and call sites in this file stay
|
||||
decoupled from the helper's home module.
|
||||
"""
|
||||
return tool.Blender.is_transform_modal_active(context)
|
||||
|
||||
|
||||
def _hide_all_non_modal_gizmos(group) -> None:
|
||||
@@ -4877,7 +4850,14 @@ class GizmoDimension(GizmoMovable):
|
||||
|
||||
self.init_value = click_distance
|
||||
|
||||
if self.initial_snap_state and self.active_obj:
|
||||
# Schematic gizmos opt out of dimension snap. Force the header
|
||||
# indicator to ``off`` for the drag's duration so the user sees the
|
||||
# state matches behaviour; ``exit`` restores ``initial_snap_state``.
|
||||
# Skipping the snap cache here also avoids the per-drag mesh probe.
|
||||
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
|
||||
if not snap_supported:
|
||||
context.scene.tool_settings.use_snap = False
|
||||
elif self.initial_snap_state and self.active_obj:
|
||||
build_snap_cache(context, self.active_obj)
|
||||
self._snap_cache_built = True
|
||||
|
||||
@@ -4919,11 +4899,18 @@ class GizmoDimension(GizmoMovable):
|
||||
if not region or not rv3d:
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
|
||||
# Group-level opt-out: schematic gizmos float in viewport space, so
|
||||
# global-snap-to-scene-vertices would produce spurious value jumps.
|
||||
# The fallback (``True``) covers any gizmo whose group is not a
|
||||
# ``BaseParametricGizmoGroup``.
|
||||
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
|
||||
|
||||
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
|
||||
build_snap_cache(context, self.active_obj)
|
||||
self._snap_cache_built = True
|
||||
if snap_supported:
|
||||
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
|
||||
|
||||
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
|
||||
build_snap_cache(context, self.active_obj)
|
||||
self._snap_cache_built = True
|
||||
|
||||
current_coord = (event.mouse_region_x, event.mouse_region_y)
|
||||
|
||||
@@ -4947,7 +4934,7 @@ class GizmoDimension(GizmoMovable):
|
||||
|
||||
delta = (current_3d - self.start_location).dot(axis_direction)
|
||||
|
||||
if tool_settings.use_snap and self.active_obj:
|
||||
if snap_supported and tool_settings.use_snap and self.active_obj:
|
||||
# Snap the dimension tip (not mouse position) to target
|
||||
# Calculate where the dimension tip would be with current delta
|
||||
# The tip is at: gizmo_origin + axis * (init_value + delta)
|
||||
@@ -5320,6 +5307,13 @@ class BaseParametricGizmoGroup:
|
||||
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
|
||||
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
|
||||
|
||||
# Default: dimension drags respect Blender's global snap (Ctrl-toggleable
|
||||
# during drag). Subclasses whose dimensions float in viewport space rather
|
||||
# than aligning to real-world geometry should override to ``False`` —
|
||||
# snapping to scene vertices in that case produces spurious value jumps
|
||||
# as the mouse crosses unrelated meshes.
|
||||
snap_enabled_on_dimensions: bool = True
|
||||
|
||||
# === Icon Gizmo Layout (meters) ===
|
||||
# Icons are positioned in a horizontal row above the element:
|
||||
# [Validate] [Cancel] [Cycle]
|
||||
@@ -6580,6 +6574,11 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
|
||||
# list and become no-ops. The schematic equivalents below take their place.
|
||||
dimension_gizmo_props: list[DimensionGizmoConfig] = []
|
||||
|
||||
# Schematic dimensions float in billboarded viewport space, not aligned to
|
||||
# real-world geometry. Snapping the dragged tip to scene vertices would
|
||||
# produce nonsensical value jumps as the mouse crosses unrelated meshes.
|
||||
snap_enabled_on_dimensions: bool = False
|
||||
|
||||
# Declarative dimension configuration consumed by ``setup_schematic_dimensions``
|
||||
# and ``update_schematic_dimensions``. Each config produces one
|
||||
# ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local
|
||||
|
||||
@@ -890,6 +890,16 @@ class OverrideDelete(bpy.types.Operator):
|
||||
# Track aggregates before deleting their parts
|
||||
aggregates_to_check = self.track_aggregates(objects_to_remove)
|
||||
|
||||
# Snapshot the set of IFC entity ids being deleted in this batch so the
|
||||
# connection-rel cascade inside `delete_ifc_object` can suppress
|
||||
# partner-side regenerate when the partner is also about to vanish.
|
||||
batch_being_deleted_ids: set[int] = set()
|
||||
for obj in objects_to_remove:
|
||||
if not tool.Blender.is_valid_data_block(obj):
|
||||
continue
|
||||
if (entity := tool.Ifc.get_entity(obj)) is not None:
|
||||
batch_being_deleted_ids.add(entity.id())
|
||||
|
||||
clear_active_object = True
|
||||
|
||||
for i, obj in enumerate(objects_to_remove, 1):
|
||||
@@ -931,7 +941,7 @@ class OverrideDelete(bpy.types.Operator):
|
||||
if tool.Drawing.is_auto_annotation(element):
|
||||
self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.")
|
||||
continue
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids)
|
||||
elif tool.Geometry.is_representation_item(obj):
|
||||
tool.Geometry.delete_ifc_item(obj)
|
||||
else:
|
||||
@@ -1030,7 +1040,10 @@ class OverrideDelete(bpy.types.Operator):
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
continue
|
||||
array_parents.add(ifc_file.by_guid(pset["Parent"]))
|
||||
try:
|
||||
array_parents.add(ifc_file.by_guid(pset["Parent"]))
|
||||
except RuntimeError:
|
||||
continue
|
||||
|
||||
for array_parent in array_parents:
|
||||
array_parent_obj = tool.Ifc.get_object(array_parent)
|
||||
|
||||
@@ -110,6 +110,9 @@ classes = (
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoPairDisconnect,
|
||||
wall.GizmoSlabEdition,
|
||||
wall.GizmoSlabUnjoinWalls,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
@@ -120,7 +123,7 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.DisconnectElements,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
@@ -154,11 +157,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 +191,7 @@ classes = (
|
||||
prop.BIMDoorProperties,
|
||||
prop.BIMRailingProperties,
|
||||
prop.BIMRoofProperties,
|
||||
prop.BIMSlabProperties,
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPipeSegmentProperties,
|
||||
prop.BIMDuctSegmentProperties,
|
||||
@@ -246,9 +253,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,
|
||||
@@ -269,9 +280,7 @@ classes = (
|
||||
mep.MEPAddObstruction,
|
||||
mep.MEPAddTransition,
|
||||
mep.MEPAddBend,
|
||||
mep.MEPUnjoinAtPort,
|
||||
mep.MEPRemoveTerminalFitting,
|
||||
mep.MEPUnjoinPair,
|
||||
mep.SelectMEPPathMembers,
|
||||
mep.MEPJoinSegments,
|
||||
mep_bend_preview.EnableBendPreview,
|
||||
@@ -368,6 +377,11 @@ def unregister():
|
||||
# half-unloaded module state.
|
||||
opening.DecorationsHandler.uninstall()
|
||||
|
||||
# Network path overlays attach SpaceView3D draw handlers on toggle;
|
||||
# uninstall here so addon disable / Blender shutdown doesn't leak them.
|
||||
decorator.MEPSystemPathDecorator.uninstall()
|
||||
decorator.WallSystemPathDecorator.uninstall()
|
||||
|
||||
if not bpy.app.background:
|
||||
for tool_data in reversed(tools):
|
||||
bpy.utils.unregister_tool(tool_data.tool)
|
||||
|
||||
@@ -423,7 +423,11 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
pset = tool.Ifc.get().by_id(pset["id"])
|
||||
for array in arrays:
|
||||
for child in set(array["children"]):
|
||||
if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
|
||||
try:
|
||||
child_element = tool.Ifc.get().by_guid(child)
|
||||
except RuntimeError:
|
||||
continue
|
||||
if child_obj := tool.Ifc.get_object(child_element):
|
||||
tool.Geometry.delete_ifc_object(child_obj)
|
||||
array["children"].clear()
|
||||
# Always operate on the parent — this operator can be invoked with
|
||||
|
||||
@@ -51,6 +51,10 @@ class AuthoringData:
|
||||
|
||||
@classmethod
|
||||
def load(cls, ifc_element_type: Optional[str] = None):
|
||||
# ``is_loaded`` is set first as a recursion guard: one of the data
|
||||
# computations evaluates a PropertyGroup enum's ``items`` callback,
|
||||
# which re-enters this method. Without the guard, load recurses to
|
||||
# RecursionError.
|
||||
cls.is_loaded = True
|
||||
cls.props = tool.Model.get_model_props()
|
||||
cls.data["default_container"] = cls.default_container()
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from math import cos, pi, radians, sin, tan
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
@@ -43,6 +44,7 @@ from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.decorator_cache import TokenCache
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
ARC_SEGMENTS,
|
||||
DOOR_SWING_ANGLE_MAX,
|
||||
@@ -62,6 +64,41 @@ def highlight_color(color, alpha=0.1):
|
||||
return color
|
||||
|
||||
|
||||
def _stroke_lines_alpha(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
line_width: float,
|
||||
line_alpha: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` (a list of (start, end) tuples) as one anti-aliased
|
||||
LINES batch in world space. Early-returns when ``context.region`` is
|
||||
unavailable (e.g. when called from a ``_RestrictContext``)."""
|
||||
if not segments:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int]] = []
|
||||
for start, end in segments:
|
||||
base = len(verts)
|
||||
verts.append(tuple(start))
|
||||
verts.append(tuple(end))
|
||||
indices.append((base, base + 1))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("viewportSize", (region.width, region.height))
|
||||
shader.uniform_float("lineWidth", line_width)
|
||||
shader.uniform_float("color", (*color_rgb, line_alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
class ProfileDecorator:
|
||||
installed = None
|
||||
|
||||
@@ -2032,46 +2069,6 @@ class BoundingBoxDecorator:
|
||||
co2.y -= y_overlap / 2 + min_spacing
|
||||
|
||||
|
||||
def _fill_quads_alpha(
|
||||
context: bpy.types.Context,
|
||||
quads: list[
|
||||
tuple[
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
]
|
||||
],
|
||||
color_rgb: tuple[float, float, float],
|
||||
alpha: float,
|
||||
) -> None:
|
||||
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
|
||||
order) as one TRIS batch with two triangles per quad."""
|
||||
if not quads:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int, int]] = []
|
||||
for quad in quads:
|
||||
if len(quad) != 4:
|
||||
continue
|
||||
base = len(verts)
|
||||
verts.extend(tuple(v) for v in quad)
|
||||
indices.append((base, base + 1, base + 2))
|
||||
indices.append((base, base + 2, base + 3))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", (*color_rgb, alpha))
|
||||
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
def compute_mep_join_location():
|
||||
"""Midpoint between the closest endpoint pair of two selected MEP
|
||||
segments — the world location where a connecting fitting (bend /
|
||||
@@ -2529,3 +2526,481 @@ def draw_polyline_segments(
|
||||
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
|
||||
|
||||
|
||||
class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Shared scaffolding for "BFS-walk a connected IFC network from a selected
|
||||
seed and overlay its schematic path" viewport decorators.
|
||||
|
||||
Subclasses implement three hooks:
|
||||
|
||||
``_is_seed_element(element)``: True if ``element`` can seed a walk
|
||||
``_walk(start_element)``: list of network elements reachable from the seed
|
||||
``_build_geometry(connected)``: ``(lines, free_points, connection_points)``
|
||||
for one walk pass; free dots render in the base selected color,
|
||||
connection dots in the "special" slot so junctions stand out
|
||||
|
||||
Lifecycle each redraw: gate on ``BIMModelProperties.show_paths`` (the
|
||||
shared toggle for all network-path overlays), find the first selected
|
||||
seed element, walk the network (cached per seed-GUID per IFC file), and
|
||||
render lines + connection-node dots. Geometry is memoised through a
|
||||
``TokenCache`` keyed on the decorator-cache token, so depsgraph / undo /
|
||||
redo / load all invalidate the resolved world-space pass without
|
||||
re-walking.
|
||||
|
||||
Install / uninstall is driven by the central addon-load handler and
|
||||
by the toggle's ``update`` callback, so flipping the property takes
|
||||
effect immediately without a Blender restart."""
|
||||
|
||||
# Network-path lines + junction dots render in ``decorator_color_selected``
|
||||
# (Bonsai's palette slot for "what the user is currently inspecting"); free
|
||||
# endpoints (dangling chain tips) switch to ``decorator_color_special`` so
|
||||
# the end of the line stands apart from interior junctions at a glance.
|
||||
LINE_WIDTH = 1.3
|
||||
LINE_ALPHA = 0.85
|
||||
# Sized larger than LINE_WIDTH so connection nodes read as discrete
|
||||
# points rather than line thickenings.
|
||||
DOT_SIZE = 4.0
|
||||
# Squared distance under which two emitted dots are treated as the same
|
||||
# connection node. In Blender units (typically meters), 1e-4 m ≈ 0.1 mm
|
||||
# — below the precision at which two IFC reference-line endpoints would
|
||||
# ever be authored as "the same join" but not so tight that float drift
|
||||
# from coordinate composition misses a real coincidence.
|
||||
CONNECTION_EPS_SQ = 1e-4 * 1e-4
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP
|
||||
# integer ids rather than ``entity_instance`` references — re-resolved
|
||||
# via ``ifc_file.by_id`` on each cache hit. Structurally rules out
|
||||
# the dangling-SWIG-handle class of bug: an entity removed between
|
||||
# frames either bumps geom_gen (cache miss → re-walk) or fails to
|
||||
# re-resolve (handled below by re-walking). Compare ``ifc_file`` with
|
||||
# ``is`` (not id()) so a GC-recycled id() can't produce a false hit.
|
||||
self._cached_start_guid: str | None = None
|
||||
self._cached_ifc_file: Any = None
|
||||
self._cached_geom_gen: int = -1
|
||||
self._cached_walk_ids: list[int] = []
|
||||
# Geometry cache: shared TokenCache. Key folds in geom_gen so IFC
|
||||
# mutations that don't surface via the depsgraph still flush the
|
||||
# resolved world-space lines and dots.
|
||||
self._geom_cache: TokenCache[
|
||||
tuple[
|
||||
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
]
|
||||
] = TokenCache()
|
||||
# One-shot guards so a corrupted walk or build surfaces in the console
|
||||
# once per decorator instance instead of every redraw.
|
||||
self._walk_failure_logged: bool = False
|
||||
self._build_failure_logged: bool = False
|
||||
# Short-circuit re-running a known-broken walk or build for the same
|
||||
# seed every frame; cleared the moment the user picks a different seed.
|
||||
self._failed_seed_guid: str | None = None
|
||||
|
||||
_ABSTRACT_HOOKS = ("_is_seed_element", "_walk", "_build_geometry")
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
# Pin the template-method contract at class-definition time, mirroring
|
||||
# ViewportDecorator's draw_method check: a subclass that forgets to
|
||||
# override one of the three hooks would otherwise pass class creation
|
||||
# and only raise NotImplementedError on the first walk — deferred long
|
||||
# past the offending declaration.
|
||||
missing = [
|
||||
name for name in cls._ABSTRACT_HOOKS if getattr(cls, name) is getattr(_ConnectedNetworkPathDecorator, name)
|
||||
]
|
||||
if missing:
|
||||
raise TypeError(f"{cls.__name__}: must override abstract hook(s) {sorted(missing)}")
|
||||
|
||||
def _is_seed_element(self, element: Any) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def _walk(self, start_element: Any) -> list[Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def _build_geometry(
|
||||
self,
|
||||
connected: list[Any],
|
||||
) -> tuple[
|
||||
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
]:
|
||||
"""Resolve world-space line segments + dots for one walk pass. Returns
|
||||
``(lines, free_points, connection_points)`` — free dots get the base
|
||||
selected color, connection dots get the special color so junctions
|
||||
between two consecutive elements pop out. Never raises; skips
|
||||
degenerate elements."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _partition_points_by_coincidence(
|
||||
cls,
|
||||
points: list[tuple[float, float, float]],
|
||||
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]] = (),
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
|
||||
"""Split ``points`` into ``(free, connection)``. A point is "connection"
|
||||
when (a) at least one other point in the list lies within
|
||||
``CONNECTION_EPS_SQ`` (corner / end-to-end joins), or (b) it lies within
|
||||
``CONNECTION_EPS_SQ`` of the interior of any segment in ``lines``
|
||||
(T-junctions / ATPATH joins, where one wall's end lands on another
|
||||
wall's axis interior rather than its endpoint). Connection points
|
||||
dedupe to one representative each so coincident dots don't stack the
|
||||
same color."""
|
||||
eps_sq = cls.CONNECTION_EPS_SQ
|
||||
n = len(points)
|
||||
shared = [False] * n
|
||||
for i in range(n):
|
||||
xi, yi, zi = points[i]
|
||||
for j in range(i + 1, n):
|
||||
xj, yj, zj = points[j]
|
||||
dx, dy, dz = xi - xj, yi - yj, zi - zj
|
||||
if dx * dx + dy * dy + dz * dz <= eps_sq:
|
||||
shared[i] = True
|
||||
shared[j] = True
|
||||
for i, point in enumerate(points):
|
||||
if shared[i]:
|
||||
continue
|
||||
if cls._point_touches_any_segment_interior(point, lines, eps_sq):
|
||||
shared[i] = True
|
||||
free: list[tuple[float, float, float]] = []
|
||||
connection: list[tuple[float, float, float]] = []
|
||||
seen_connection: list[tuple[float, float, float]] = []
|
||||
for i, point in enumerate(points):
|
||||
if not shared[i]:
|
||||
free.append(point)
|
||||
continue
|
||||
for existing in seen_connection:
|
||||
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
|
||||
if dx * dx + dy * dy + dz * dz <= eps_sq:
|
||||
break
|
||||
else:
|
||||
seen_connection.append(point)
|
||||
connection.append(point)
|
||||
return free, connection
|
||||
|
||||
@staticmethod
|
||||
def _point_touches_any_segment_interior(
|
||||
point: tuple[float, float, float],
|
||||
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
eps_sq: float,
|
||||
) -> bool:
|
||||
"""True iff ``point`` lies within ``sqrt(eps_sq)`` of the interior of
|
||||
any segment in ``lines``. Endpoints are excluded so a point cannot
|
||||
match its own owning segment via either of that segment's tips — the
|
||||
endpoint-coincidence pass already handles those cases. The qualifying
|
||||
projection must land strictly inside the segment (``0 < t < 1``) AND
|
||||
sit further than ``eps`` from either tip, catching ATPATH/T-junction
|
||||
joins without false-flagging walls that share a corner."""
|
||||
px, py, pz = point
|
||||
for (ax, ay, az), (bx, by, bz) in lines:
|
||||
dxa, dya, dza = px - ax, py - ay, pz - az
|
||||
if dxa * dxa + dya * dya + dza * dza <= eps_sq:
|
||||
continue
|
||||
dxb, dyb, dzb = px - bx, py - by, pz - bz
|
||||
if dxb * dxb + dyb * dyb + dzb * dzb <= eps_sq:
|
||||
continue
|
||||
ex, ey, ez = bx - ax, by - ay, bz - az
|
||||
seg_len_sq = ex * ex + ey * ey + ez * ez
|
||||
if seg_len_sq <= eps_sq:
|
||||
continue
|
||||
t = (dxa * ex + dya * ey + dza * ez) / seg_len_sq
|
||||
if t <= 0.0 or t >= 1.0:
|
||||
continue
|
||||
qx, qy, qz = ax + t * ex, ay + t * ey, az + t * ez
|
||||
dx, dy, dz = px - qx, py - qy, pz - qz
|
||||
if dx * dx + dy * dy + dz * dz <= eps_sq:
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
model_props = tool.Model.get_model_props()
|
||||
if not getattr(model_props, "show_paths", False):
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
|
||||
start_element = None
|
||||
active = context.active_object
|
||||
if active is not None:
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is not None and self._is_seed_element(element):
|
||||
start_element = element
|
||||
if start_element is None:
|
||||
for obj in context.selected_objects or []:
|
||||
if obj is active:
|
||||
continue
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not self._is_seed_element(element):
|
||||
continue
|
||||
start_element = element
|
||||
break
|
||||
if start_element is None:
|
||||
self._cached_start_guid = None
|
||||
self._cached_walk = []
|
||||
return
|
||||
|
||||
start_guid = start_element.GlobalId
|
||||
if start_guid == self._failed_seed_guid:
|
||||
return
|
||||
current_geom_gen = tool.Parametric.get_geom_generation()
|
||||
connected: list[Any] | None = None
|
||||
if (
|
||||
start_guid == self._cached_start_guid
|
||||
and ifc_file is self._cached_ifc_file
|
||||
and current_geom_gen == self._cached_geom_gen
|
||||
and self._cached_walk_ids
|
||||
):
|
||||
try:
|
||||
connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids]
|
||||
except RuntimeError:
|
||||
# An entity was removed without bumping geom_gen — rare but
|
||||
# possible from non-operator code paths. Force a re-walk
|
||||
# rather than feeding a stale handle to _build_geometry.
|
||||
connected = None
|
||||
if connected is None:
|
||||
try:
|
||||
connected = self._walk(start_element)
|
||||
except Exception:
|
||||
if not self._walk_failure_logged:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
self._walk_failure_logged = True
|
||||
self._cached_walk_ids = []
|
||||
self._failed_seed_guid = start_guid
|
||||
return
|
||||
self._cached_start_guid = start_guid
|
||||
self._cached_ifc_file = ifc_file
|
||||
self._cached_geom_gen = current_geom_gen
|
||||
self._cached_walk_ids = [e.id() for e in connected]
|
||||
if not connected:
|
||||
return
|
||||
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
line_color = tuple(prefs.decorator_color_selected[:3])
|
||||
# Junction dots get the "selected" palette slot (green by default) so
|
||||
# they read as the currently-inspected network's spine; free endpoints
|
||||
# get the "special" slot (blue by default) so dangling line ends stand
|
||||
# apart from junctions at a glance.
|
||||
connection_color = line_color
|
||||
free_color = tuple(prefs.decorator_color_special[:3])
|
||||
|
||||
try:
|
||||
lines, free_points, connection_points = self._geom_cache.get_or_compute(
|
||||
(start_guid, id(ifc_file), current_geom_gen),
|
||||
lambda: self._build_geometry(connected),
|
||||
)
|
||||
except Exception:
|
||||
if not self._build_failure_logged:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
self._build_failure_logged = True
|
||||
self._failed_seed_guid = start_guid
|
||||
return
|
||||
|
||||
if lines:
|
||||
_stroke_lines_alpha(context, lines, line_color, self.LINE_WIDTH, self.LINE_ALPHA)
|
||||
|
||||
if free_points or connection_points:
|
||||
# POINTS via UNIFORM_COLOR; point_size_set only affects the next batch.
|
||||
point_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
point_shader.bind()
|
||||
gpu.state.point_size_set(self.DOT_SIZE)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
if free_points:
|
||||
point_shader.uniform_float("color", (*free_color, self.LINE_ALPHA))
|
||||
batch = batch_for_shader(point_shader, "POINTS", {"pos": free_points})
|
||||
batch.draw(point_shader)
|
||||
if connection_points:
|
||||
point_shader.uniform_float("color", (*connection_color, self.LINE_ALPHA))
|
||||
batch = batch_for_shader(point_shader, "POINTS", {"pos": connection_points})
|
||||
batch.draw(point_shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
class MEPSystemPathDecorator(_ConnectedNetworkPathDecorator):
|
||||
"""Schematic-path overlay for the selected MEP element's connected
|
||||
distribution system.
|
||||
|
||||
Walk: BFS through ``IfcRelConnectsPorts`` from the first selected MEP
|
||||
element. Segments render as one axis line + endpoint dots. Fittings
|
||||
render as:
|
||||
|
||||
- 2-port (transition, coupler, bend): one line port-to-port, keeping
|
||||
the schematic continuous through the fitting. The "spider from
|
||||
origin" pattern produces V-shaped flares when the fitting's local
|
||||
origin is offset from its ports.
|
||||
- 3+-port (tee, cross, branching): spider from origin to each port.
|
||||
Drawing all N*(N-1)/2 port pairs would clutter the view at high N
|
||||
(N=4 → 6 lines); the spider gives one line per port.
|
||||
- 0-port / 1-port: degenerate, no lines (dots still emit)."""
|
||||
|
||||
def _is_seed_element(self, element: Any) -> bool:
|
||||
return tool.System.is_mep_element(element)
|
||||
|
||||
def _walk(self, start_element: Any) -> list[Any]:
|
||||
return tool.System.walk_connected_mep_elements(start_element)
|
||||
|
||||
def _build_geometry(
|
||||
self,
|
||||
connected: list[Any],
|
||||
) -> tuple[
|
||||
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
]:
|
||||
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
port_positions: list[tuple[float, float, float]] = []
|
||||
for element in connected:
|
||||
if element.is_a("IfcFlowSegment"):
|
||||
if not tool.Geometry.has_axis_representation(element):
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
continue
|
||||
start_world, end_world = tool.Model.get_flow_segment_axis(obj)
|
||||
lines.append((tuple(start_world), tuple(end_world)))
|
||||
# Segment ports sit at the two axis endpoints — emit dots so
|
||||
# the connection node is visible whether the neighbour is a
|
||||
# fitting (also emits) or another segment (doesn't).
|
||||
port_positions.append(tuple(start_world))
|
||||
port_positions.append(tuple(end_world))
|
||||
elif element.is_a("IfcFlowFitting"):
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
continue
|
||||
ports = tool.System.get_ports(element)
|
||||
port_world_positions = [tool.System.get_port_world_position(p) for p in ports]
|
||||
if len(port_world_positions) == 2:
|
||||
lines.append((tuple(port_world_positions[0]), tuple(port_world_positions[1])))
|
||||
elif len(port_world_positions) >= 3:
|
||||
origin = obj.matrix_world.translation
|
||||
for port_pos in port_world_positions:
|
||||
lines.append((tuple(origin), tuple(port_pos)))
|
||||
for port_pos in port_world_positions:
|
||||
port_positions.append(tuple(port_pos))
|
||||
free_points, connection_points = self._partition_points_by_coincidence(port_positions)
|
||||
return lines, free_points, connection_points
|
||||
|
||||
|
||||
class WallSystemPathDecorator(_ConnectedNetworkPathDecorator):
|
||||
"""Schematic-path overlay for the selected wall's connected wall network.
|
||||
|
||||
Walk: BFS through ``IfcRelConnectsPathElements`` from the first selected
|
||||
wall. Each wall renders as one reference-line segment + a dot at each
|
||||
axis endpoint. Endpoints are classified by IFC topology — every wall in
|
||||
the walked set inspects its ``IfcRelConnectsPathElements`` rels filtered
|
||||
to walls in the same set, and uses ``Relating*``/``Related*ConnectionType``
|
||||
(ATSTART / ATEND / ATPATH) to decide which endpoint participates. ATPATH
|
||||
rels also emit a connection dot at the canonical join location (a T-meets
|
||||
point sits on the through-wall's interior, not at any endpoint). The
|
||||
framework's geometric classifier is bypassed for walls because authoring
|
||||
tolerance and post-edit float drift commonly exceed the 0.1 mm coincidence
|
||||
threshold, so T-junctions otherwise fell into the free bucket."""
|
||||
|
||||
def _is_seed_element(self, element: Any) -> bool:
|
||||
return element.is_a("IfcWall") and tool.Geometry.has_axis_representation(element)
|
||||
|
||||
def _walk(self, start_element: Any) -> list[Any]:
|
||||
return tool.Wall.walk_connected_walls(start_element)
|
||||
|
||||
def _build_geometry(
|
||||
self,
|
||||
connected: list[Any],
|
||||
) -> tuple[
|
||||
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
]:
|
||||
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
|
||||
for element in connected:
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
continue
|
||||
ref = tool.Wall.get_world_reference_line(obj)
|
||||
if ref is None:
|
||||
continue
|
||||
p1, p2 = tuple(ref[0]), tuple(ref[1])
|
||||
refs[element.id()] = (p1, p2)
|
||||
lines.append((p1, p2))
|
||||
|
||||
free_points, connection_points = self._classify_endpoints_from_rels(connected, refs)
|
||||
connection_points = self._dedupe_close_points(connection_points, self.CONNECTION_EPS_SQ)
|
||||
return lines, free_points, connection_points
|
||||
|
||||
@staticmethod
|
||||
def _classify_endpoints_from_rels(
|
||||
connected: Sequence[Any],
|
||||
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
|
||||
"""For each wall in ``connected`` with a reference line in ``refs``,
|
||||
classify its endpoints by walking its ``IfcRelConnectsPathElements``
|
||||
rels filtered to walls also in ``refs``. ATSTART side present →
|
||||
reference-line start is a connection; ATEND side present → reference-
|
||||
line end is a connection; otherwise free. ATPATH side present → emit
|
||||
an extra connection dot at the canonical join via
|
||||
``tool.Wall.path_connection_location_world``. Returns
|
||||
``(free, connection)`` un-deduped."""
|
||||
free_points: list[tuple[float, float, float]] = []
|
||||
connection_points: list[tuple[float, float, float]] = []
|
||||
for element in connected:
|
||||
self_seg = refs.get(element.id())
|
||||
if self_seg is None:
|
||||
continue
|
||||
sides: set[str] = set()
|
||||
atpath_dots: list[tuple[float, float, float]] = []
|
||||
for rel in getattr(element, "ConnectedTo", []) or ():
|
||||
if not rel.is_a("IfcRelConnectsPathElements"):
|
||||
continue
|
||||
other = rel.RelatedElement
|
||||
other_seg = refs.get(other.id()) if other is not None else None
|
||||
if other_seg is None:
|
||||
continue
|
||||
self_type = rel.RelatingConnectionType
|
||||
other_type = rel.RelatedConnectionType
|
||||
sides.add(self_type)
|
||||
if self_type == "ATPATH":
|
||||
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
|
||||
atpath_dots.append(tuple(join))
|
||||
for rel in getattr(element, "ConnectedFrom", []) or ():
|
||||
if not rel.is_a("IfcRelConnectsPathElements"):
|
||||
continue
|
||||
other = rel.RelatingElement
|
||||
other_seg = refs.get(other.id()) if other is not None else None
|
||||
if other_seg is None:
|
||||
continue
|
||||
self_type = rel.RelatedConnectionType
|
||||
other_type = rel.RelatingConnectionType
|
||||
sides.add(self_type)
|
||||
if self_type == "ATPATH":
|
||||
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
|
||||
atpath_dots.append(tuple(join))
|
||||
p1, p2 = self_seg
|
||||
(connection_points if "ATSTART" in sides else free_points).append(p1)
|
||||
(connection_points if "ATEND" in sides else free_points).append(p2)
|
||||
connection_points.extend(atpath_dots)
|
||||
return free_points, connection_points
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_close_points(
|
||||
points: Sequence[tuple[float, float, float]],
|
||||
eps_sq: float,
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Drop later occurrences of points within ``sqrt(eps_sq)`` of an
|
||||
earlier one. Used to collapse overlapping connection dots so an ATPATH
|
||||
join computed at the same point as a neighbour's wall endpoint
|
||||
renders once."""
|
||||
result: list[tuple[float, float, float]] = []
|
||||
for point in points:
|
||||
for existing in result:
|
||||
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
|
||||
if dx * dx + dy * dy + dz * dz <= eps_sq:
|
||||
break
|
||||
else:
|
||||
result.append(point)
|
||||
return result
|
||||
|
||||
@@ -693,27 +693,6 @@ def get_connected_element_at_segment_port(segment, at_segment_start):
|
||||
return tool.System.get_port_relating_element(connected_port)
|
||||
|
||||
|
||||
def find_fitting_between_segments(segment_a, segment_b):
|
||||
"""Single IfcFlowFitting bridging segment_a and segment_b via ports, or
|
||||
``None`` if no fitting (or multiple fittings — only direct one-fitting
|
||||
joins handled)."""
|
||||
if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")):
|
||||
return None
|
||||
b_ports_set = set(tool.System.get_ports(segment_b))
|
||||
for a_port in tool.System.get_ports(segment_a):
|
||||
connected_port = tool.System.get_connected_port(a_port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
fitting = tool.System.get_port_relating_element(connected_port)
|
||||
if fitting is None or not fitting.is_a("IfcFlowFitting"):
|
||||
continue
|
||||
for fitting_port in tool.System.get_ports(fitting):
|
||||
other_port = tool.System.get_connected_port(fitting_port)
|
||||
if other_port is not None and other_port in b_ports_set:
|
||||
return fitting
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_active_mep_segment(operator, context):
|
||||
"""Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting.
|
||||
|
||||
@@ -808,52 +787,6 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Delete the IfcFlowFitting that bridges a segment's port to a second element.
|
||||
|
||||
Used when the connection at the port is in the JOINED state (the fitting
|
||||
has at least one other port connecting to a different element). The
|
||||
segment isn't resized — only the bridging fitting is removed. Refuses
|
||||
to act on an OBSTRUCTION fitting (those are routed through
|
||||
``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment
|
||||
to absorb the freed length)."""
|
||||
|
||||
bl_idname = "bim.mep_unjoin_at_port"
|
||||
bl_label = "Unjoin MEP Segment at Port"
|
||||
bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
|
||||
position: bpy.props.EnumProperty(
|
||||
name="Port",
|
||||
items=[
|
||||
("START", "At Start", "Operate on the segment's start port"),
|
||||
("END", "At End", "Operate on the segment's end port"),
|
||||
],
|
||||
default="END",
|
||||
)
|
||||
|
||||
def _execute(self, context):
|
||||
resolved = _require_port_state(self, context, PORT_JOINED, "joining")
|
||||
if resolved is None:
|
||||
return {"CANCELLED"}
|
||||
element, at_segment_start = resolved
|
||||
|
||||
fitting = get_connected_element_at_segment_port(element, at_segment_start)
|
||||
if fitting is None or not fitting.is_a("IfcFlowFitting"):
|
||||
self.report({"ERROR"}, "Connected port does not lead to a fitting.")
|
||||
return {"CANCELLED"}
|
||||
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
|
||||
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
|
||||
return {"CANCELLED"}
|
||||
|
||||
fitting_obj = tool.Ifc.get_object(fitting)
|
||||
if fitting_obj is None:
|
||||
self.report({"ERROR"}, "Fitting has no Blender object.")
|
||||
return {"CANCELLED"}
|
||||
tool.Geometry.delete_ifc_object(fitting_obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Remove the terminal fitting at a segment's named port.
|
||||
|
||||
@@ -906,44 +839,6 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Delete the IfcFlowFitting joining two selected MEP segments.
|
||||
|
||||
Removes the fitting; segments are left in place for the user to reposition."""
|
||||
|
||||
bl_idname = "bim.mep_unjoin_pair"
|
||||
bl_label = "Unjoin MEP Segments"
|
||||
bl_description = "Delete the fitting joining the two selected MEP segments"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not _n_mep_selected(2):
|
||||
cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context):
|
||||
selected_objs = tool.Blender.get_selected_objects()
|
||||
elements = [tool.Ifc.get_entity(o) for o in selected_objs]
|
||||
if any(e is None or not e.is_a("IfcFlowSegment") for e in elements):
|
||||
self.report({"ERROR"}, "Both selected objects must be MEP segments.")
|
||||
return {"CANCELLED"}
|
||||
fitting = find_fitting_between_segments(elements[0], elements[1])
|
||||
if fitting is None:
|
||||
self.report({"ERROR"}, "No single fitting joins the selected segments.")
|
||||
return {"CANCELLED"}
|
||||
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
|
||||
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
|
||||
return {"CANCELLED"}
|
||||
fitting_obj = tool.Ifc.get_object(fitting)
|
||||
if fitting_obj is None:
|
||||
self.report({"ERROR"}, "Fitting has no Blender object.")
|
||||
return {"CANCELLED"}
|
||||
tool.Geometry.delete_ifc_object(fitting_obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SelectMEPPathMembers(bpy.types.Operator):
|
||||
"""Replace the selection with every MEP element reachable from the active
|
||||
one via IfcRelConnectsPorts — the entire connected distribution network."""
|
||||
@@ -2677,10 +2572,22 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
|
||||
|
||||
|
||||
def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
|
||||
"""True iff the active object is a parametric BEND fitting eligible for
|
||||
the bend-preview re-edit path. Re-edit reads parameters from the type's
|
||||
``BBIM_Fitting`` pset, so that pset's presence is the ground truth for
|
||||
re-editability — not the body representation class. The bend creation
|
||||
path tessellates the swept-disk body as an upstream-geometry-kernel
|
||||
workaround, so a freshly-committed bend's body contains only an
|
||||
``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly
|
||||
returns False for it; the pset gate is what keeps the pen icon
|
||||
eligible."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not _is_bend_fitting(element):
|
||||
return False
|
||||
return tool.System.has_parametric_body(element)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is None:
|
||||
return False
|
||||
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
|
||||
|
||||
|
||||
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
@@ -2763,20 +2670,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
),
|
||||
IconActionConfig(
|
||||
name="unjoin_start",
|
||||
icon="VIEW3D_GT_unjoin",
|
||||
operator="bim.mep_unjoin_at_port",
|
||||
icon="VIEW3D_GT_wall_link_toggle",
|
||||
operator="bim.disconnect_elements",
|
||||
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
|
||||
),
|
||||
IconActionConfig(
|
||||
name="unjoin_end",
|
||||
icon="VIEW3D_GT_unjoin",
|
||||
operator="bim.mep_unjoin_at_port",
|
||||
icon="VIEW3D_GT_wall_link_toggle",
|
||||
operator="bim.disconnect_elements",
|
||||
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
|
||||
),
|
||||
IconActionConfig(
|
||||
name="unjoin_pair",
|
||||
icon="VIEW3D_GT_unjoin",
|
||||
operator="bim.mep_unjoin_pair",
|
||||
icon="VIEW3D_GT_wall_link_toggle",
|
||||
operator="bim.disconnect_elements",
|
||||
visibility_condition=lambda _active: _n_mep_selected(2),
|
||||
),
|
||||
]
|
||||
@@ -2794,7 +2701,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not tool.System.is_mep_element(element):
|
||||
return False
|
||||
return tool.System.has_parametric_body(element)
|
||||
if tool.System.has_parametric_body(element):
|
||||
return True
|
||||
# Bend fittings carry their parametric definition in the type's
|
||||
# ``BBIM_Fitting`` pset because the bend creation path tessellates
|
||||
# the swept-disk body (upstream geometry-kernel workaround), so
|
||||
# ``has_parametric_body`` returns False for them. Fall back to the
|
||||
# pset gate so the pen icon (re_edit_bend) stays reachable.
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is None:
|
||||
return False
|
||||
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
super().setup(context)
|
||||
@@ -2802,11 +2719,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
|
||||
@classmethod
|
||||
def _wire_anchored_icon_targets(cls, group) -> None:
|
||||
"""Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored
|
||||
icon so a click dispatches to the right port without a per-frame
|
||||
property write; apply the warning-red hover colour to destructive
|
||||
icons. Takes any object with ``action_<name>_gizmo`` attributes so
|
||||
tests can exercise the wiring without instantiating the GizmoGroup."""
|
||||
"""Pre-fill ``position`` (and ``mode`` for open-lock) on the lock
|
||||
icons so a click dispatches to the right port without a per-frame
|
||||
property write, and pre-bind the unified ``bim.disconnect_elements``
|
||||
operator on each unjoin icon so :py:meth:`position_gizmos` only has
|
||||
to update the two GUIDs per frame. Takes any object with
|
||||
``action_<name>_gizmo`` attributes so tests can exercise the wiring
|
||||
without instantiating the GizmoGroup."""
|
||||
for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items():
|
||||
gz = getattr(group, f"action_{config_name}_gizmo", None)
|
||||
if gz is None:
|
||||
@@ -2820,19 +2739,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting")
|
||||
op_props.position = position_arg
|
||||
|
||||
for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")):
|
||||
gz = getattr(group, f"action_{config_name}_gizmo", None)
|
||||
if gz is None:
|
||||
continue
|
||||
op_props = gz.target_set_operator("bim.mep_unjoin_at_port")
|
||||
op_props.position = position_arg
|
||||
|
||||
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
|
||||
group.unjoin_op_props = {}
|
||||
for config_name in cls.UNJOIN_CONFIGS:
|
||||
gz = getattr(group, f"action_{config_name}_gizmo", None)
|
||||
if gz is None:
|
||||
continue
|
||||
gz.color_highlight = warning_color
|
||||
group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements")
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Lay out icons across three regions: row above bbox top, segment
|
||||
@@ -2899,6 +2811,10 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
if not visible:
|
||||
gz.hide = True
|
||||
continue
|
||||
if config.name.startswith("unjoin_"):
|
||||
if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"):
|
||||
gz.hide = True
|
||||
continue
|
||||
if segment_endpoints is None:
|
||||
segment_endpoints = tool.Model.get_flow_segment_axis(obj)
|
||||
start_world, end_world = segment_endpoints
|
||||
@@ -2910,7 +2826,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
if len(selected) == 2:
|
||||
elements = [tool.Ifc.get_entity(o) for o in selected]
|
||||
if all(e is not None and e.is_a("IfcFlowSegment") for e in elements):
|
||||
pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False
|
||||
pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False
|
||||
else:
|
||||
pair_fitting = False
|
||||
else:
|
||||
@@ -2922,6 +2838,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
gz.hide = True
|
||||
continue
|
||||
|
||||
if config.name == "unjoin_pair":
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
pair_elements = [tool.Ifc.get_entity(o) for o in selected]
|
||||
if not self._bind_unjoin_pair(pair_elements):
|
||||
gz.hide = True
|
||||
continue
|
||||
|
||||
if not bend_anchor_attempted:
|
||||
bend_anchor = compute_mep_join_location()
|
||||
bend_anchor_attempted = True
|
||||
@@ -2950,3 +2873,33 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
if name in self.ENDPOINT_CONFIGS:
|
||||
return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO
|
||||
return self.ICON_SCALE
|
||||
|
||||
def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool:
|
||||
"""Resolve the fitting at the named port and bind both GUIDs on the
|
||||
pre-wired ``bim.disconnect_elements`` op_props. Returns False when
|
||||
the partner is unresolvable (port not joined to a disconnectable
|
||||
fitting), and the caller hides the icon."""
|
||||
element = tool.Ifc.get_entity(segment_obj)
|
||||
if element is None:
|
||||
return False
|
||||
fitting = get_connected_element_at_segment_port(element, at_segment_start)
|
||||
if fitting is None or not fitting.is_a("IfcFlowFitting"):
|
||||
return False
|
||||
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
|
||||
return False
|
||||
op_props = self.unjoin_op_props[config_name]
|
||||
op_props.element_a_guid = element.GlobalId
|
||||
op_props.element_b_guid = fitting.GlobalId
|
||||
return True
|
||||
|
||||
def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool:
|
||||
"""Bind both segment GUIDs on the pair-disconnect icon's pre-wired
|
||||
``bim.disconnect_elements`` op_props. Returns False when either side
|
||||
is missing a GlobalId (e.g. selection lost an active object), and
|
||||
the caller hides the icon."""
|
||||
if len(pair_elements) != 2 or any(e is None for e in pair_elements):
|
||||
return False
|
||||
op_props = self.unjoin_op_props["unjoin_pair"]
|
||||
op_props.element_a_guid = pair_elements[0].GlobalId
|
||||
op_props.element_b_guid = pair_elements[1].GlobalId
|
||||
return True
|
||||
|
||||
@@ -1002,6 +1002,14 @@ class EnableEditingExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
obj = context.active_object
|
||||
|
||||
# Commit any in-progress parametric (gizmo) draft on this object
|
||||
# before switching to axis-edit. Otherwise the in-memory draft
|
||||
# state is overwritten when the axis mesh is imported below,
|
||||
# silently discarding the user's pending dimension edits.
|
||||
if feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.commit_object_draft(obj, feature.finish_op)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
|
||||
axis = ifcopenshell.util.representation.get_representation(element, "Model", "Axis", "GRAPH_VIEW")
|
||||
|
||||
@@ -33,8 +33,10 @@ from bonsai.bim.module.drawing.decoration import CutDecorator
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
BoundingBoxDecorator,
|
||||
MEPSystemPathDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallSystemPathDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.door import update_door_modifier_bmesh
|
||||
from bonsai.bim.module.model.window import update_window_modifier_bmesh
|
||||
@@ -132,6 +134,19 @@ def update_slab_direction_decorator(self: "BIMModelProperties", context: bpy.typ
|
||||
SlabDirectionDecorator.uninstall()
|
||||
|
||||
|
||||
def update_paths_decorator(self: "BIMModelProperties", context: bpy.types.Context) -> None:
|
||||
"""Unified toggle for connected-element path overlays. Drives both the
|
||||
MEP and wall path decorators — each decorator's ``draw`` short-circuits
|
||||
when its kind of element isn't selected, so leaving both installed is
|
||||
cheap and lets one toggle cover any connected-element family."""
|
||||
if self.show_paths:
|
||||
MEPSystemPathDecorator.install(bpy.context)
|
||||
WallSystemPathDecorator.install(bpy.context)
|
||||
else:
|
||||
MEPSystemPathDecorator.uninstall()
|
||||
WallSystemPathDecorator.uninstall()
|
||||
|
||||
|
||||
def update_measure_xyz(self: "BIMModelProperties", context: bpy.types.Context) -> None:
|
||||
if self.show_bounding_box:
|
||||
BoundingBoxDecorator.install(context)
|
||||
@@ -228,11 +243,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:
|
||||
@@ -358,6 +369,19 @@ class BIMModelProperties(PropertyGroup):
|
||||
default=False,
|
||||
update=update_slab_direction_decorator,
|
||||
)
|
||||
show_paths: bpy.props.BoolProperty(
|
||||
name="Show Paths",
|
||||
default=False,
|
||||
update=update_paths_decorator,
|
||||
description=(
|
||||
"Trace the connected element path from the selected element. For "
|
||||
"walls, follows IfcRelConnectsPathElements and draws each "
|
||||
"connected wall's reference axis with endpoint dots. For MEP "
|
||||
"elements, follows IfcRelConnectsPorts and draws each segment's "
|
||||
"axis plus a port-to-port spider for each fitting. Toggle off to "
|
||||
"skip the BFS traversal entirely."
|
||||
),
|
||||
)
|
||||
|
||||
prev_transform_orientation_slot_type: bpy.props.StringProperty(name="Previous Gizmo Orientation Type")
|
||||
prev_show_gizmo_object_translate: bpy.props.BoolProperty(name="Previous Gizmo Translate")
|
||||
@@ -405,6 +429,7 @@ class BIMModelProperties(PropertyGroup):
|
||||
offset: float
|
||||
show_wall_axis: bool
|
||||
show_slab_direction: bool
|
||||
show_paths: bool
|
||||
|
||||
prev_transform_orientation_slot_type: str
|
||||
prev_show_gizmo_object_translate: bool
|
||||
@@ -1693,6 +1718,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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -620,6 +620,14 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
obj = context.active_object
|
||||
|
||||
# Commit any in-progress parametric (gizmo) draft on this object
|
||||
# before switching to profile-edit. Otherwise the in-memory draft
|
||||
# state is overwritten when the profile mesh is imported below,
|
||||
# silently discarding the user's pending dimension edits.
|
||||
if feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.commit_object_draft(obj, feature.finish_op)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
@@ -991,3 +999,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
tool.Model.recalculate_walls(walls)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Open the slab disconnect-access mode. Pure UI toggle: flips
|
||||
``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect
|
||||
gizmos surface on the slab. ``tool.Ifc.Operator`` base because the
|
||||
parametric framework's universal dispatcher routes through
|
||||
``tool.Parametric.run_bim_op``, which only accepts that subclass for
|
||||
undo-safe lifecycle. No IFC mutation."""
|
||||
|
||||
bl_idname = "bim.enable_editing_slab"
|
||||
bl_label = "Edit Slab Connections"
|
||||
bl_description = "Show disconnect icons for every wall clipped to this slab"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return element is not None and element.is_a("IfcSlab")
|
||||
|
||||
def _execute(self, context):
|
||||
context.active_object.BIMSlabProperties.is_editing = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Close the slab disconnect-access mode."""
|
||||
|
||||
bl_idname = "bim.cancel_editing_slab"
|
||||
bl_label = "Close Slab Edit"
|
||||
bl_description = "Hide the slab disconnect icons"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return element is not None and element.is_a("IfcSlab")
|
||||
|
||||
def _execute(self, context):
|
||||
context.active_object.BIMSlabProperties.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Close the slab disconnect-access mode. Same body as Cancel — slab
|
||||
edit is a pure UI gate with no IFC draft to commit; the framework
|
||||
requires both ``bim.finish_editing_<name>`` and
|
||||
``bim.cancel_editing_<name>`` to exist by name convention."""
|
||||
|
||||
bl_idname = "bim.finish_editing_slab"
|
||||
bl_label = "Finish Slab Edit"
|
||||
bl_description = "Hide the slab disconnect icons"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return element is not None and element.is_a("IfcSlab")
|
||||
|
||||
def _execute(self, context):
|
||||
context.active_object.BIMSlabProperties.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +63,6 @@ from bonsai.bim.module.model.decorator import (
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH,
|
||||
PolylineDecorator,
|
||||
ProductDecorator,
|
||||
_fill_quads_alpha,
|
||||
bbox_world_edges,
|
||||
draw_polyline_segments,
|
||||
)
|
||||
@@ -108,6 +108,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 +290,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 +338,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 +372,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 subject, kind in rels:
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
subject=subject,
|
||||
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 +442,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 +463,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 +715,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 +752,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 +825,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 +872,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 +941,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 +964,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 +1546,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 +1567,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 +1633,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 +1657,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 +1679,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 +1735,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 +1755,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 +1768,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 +2584,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 +2619,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 +3246,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 +3371,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 +3457,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 +3742,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 +4008,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 +4036,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 +4072,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 +4084,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 +4109,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 +4141,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):
|
||||
@@ -4468,7 +4858,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
],
|
||||
color_rgb: tuple[float, float, float],
|
||||
) -> None:
|
||||
_fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA)
|
||||
tool.Blender.draw_quads(context, quads, fill_color=(*color_rgb, self.QUAD_ALPHA))
|
||||
|
||||
@staticmethod
|
||||
def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -21,6 +21,7 @@ import bpy
|
||||
from . import operator, prop, ui
|
||||
|
||||
classes = (
|
||||
operator.AddIfcPatchPreset,
|
||||
operator.ExecuteIfcPatch,
|
||||
operator.ExtractSelectedElements,
|
||||
operator.RunMigratePatch,
|
||||
@@ -28,6 +29,7 @@ classes = (
|
||||
operator.SelectIfcPatchOutput,
|
||||
operator.UpdateIfcPatchArguments,
|
||||
prop.BIMPatchProperties,
|
||||
ui.BIM_MT_ifc_patch_presets,
|
||||
ui.BIM_PT_patch,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcpatch
|
||||
from bl_operators.presets import AddPresetBase
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
import bonsai.bim.handler
|
||||
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
|
||||
return False
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
# Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes
|
||||
# become IfcBuildingElementProxy, tessellated meshes get rebuilt as
|
||||
# IfcFacetedBrep). Confirm before running so the user knows.
|
||||
if tool.Patch.migration_is_lossy_downgrade():
|
||||
return context.window_manager.invoke_props_dialog(self, width=480)
|
||||
return self.execute(context)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR")
|
||||
column = layout.column(align=True)
|
||||
column.label(text="Geometry will be preserved as faithfully as possible:")
|
||||
column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)")
|
||||
column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep")
|
||||
column.separator()
|
||||
column.label(text="The following information is lost:")
|
||||
column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy")
|
||||
column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped")
|
||||
column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')")
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Patch.get_patch_props()
|
||||
recipe_name = props.ifc_patch_recipes
|
||||
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
|
||||
query = tool.Search.get_query_for_selected_elements()
|
||||
props.ifc_patch_args_attr[0].string_value = query
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator):
|
||||
"""Save / remove ifc-patch argument presets, scoped per recipe.
|
||||
|
||||
Presets live in the standard Blender preset directory under
|
||||
``bonsai/ifc_patch/<recipe>/`` so a preset created for ``ExtractElements``
|
||||
does not pollute the preset list for ``Migrate``. Persistence across files
|
||||
and sessions is inherited from Blender's preset system."""
|
||||
|
||||
bl_idname = "bim.add_ifc_patch_preset"
|
||||
bl_label = "Add IFC Patch Preset"
|
||||
preset_menu = "BIM_MT_ifc_patch_presets"
|
||||
preset_defines = ["props = bpy.context.scene.BIMPatchProperties"]
|
||||
|
||||
@property
|
||||
def preset_subdir(self) -> str:
|
||||
return tool.Patch.get_preset_subdir()
|
||||
|
||||
@property
|
||||
def preset_values(self) -> list[str]:
|
||||
# `Attribute.get_value_name()` returns the storage field for the
|
||||
# argument's data_type (string_value, bool_value, …). For file
|
||||
# arguments it returns the wrapping PointerProperty (`filepath_value`)
|
||||
# — the scalar path the preset needs is `.single_file` on that.
|
||||
props = tool.Patch.get_patch_props()
|
||||
values = []
|
||||
for i, arg in enumerate(props.ifc_patch_args_attr):
|
||||
field = arg.get_value_name()
|
||||
if not field:
|
||||
continue
|
||||
if arg.data_type == "file":
|
||||
field = f"{field}.single_file"
|
||||
values.append(f"props.ifc_patch_args_attr[{i}].{field}")
|
||||
return values
|
||||
|
||||
@@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
|
||||
|
||||
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
|
||||
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
|
||||
# Blender's script.execute_preset mutates the menu class's bl_label to
|
||||
# the loaded preset's display name (used as a "currently selected"
|
||||
# indicator). The label persists across recipe changes — making the new
|
||||
# recipe's menu falsely show the previous recipe's preset name. Reset
|
||||
# the label to the menu's canonical title so it always matches the
|
||||
# active recipe's preset list.
|
||||
menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None)
|
||||
if menu_cls is not None:
|
||||
menu_cls.bl_label = "IFC Patch Presets"
|
||||
|
||||
|
||||
class BIMPatchProperties(PropertyGroup):
|
||||
|
||||
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.prop import Attribute
|
||||
|
||||
|
||||
class BIM_MT_ifc_patch_presets(bpy.types.Menu):
|
||||
"""Lists ifc-patch presets for the currently selected recipe.
|
||||
|
||||
``preset_subdir`` is resolved per draw so switching recipes swaps the
|
||||
preset list without re-registering the menu."""
|
||||
|
||||
bl_label = "IFC Patch Presets"
|
||||
preset_operator = "script.execute_preset"
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
self.preset_subdir = tool.Patch.get_preset_subdir()
|
||||
bpy.types.Menu.draw_preset(self, context)
|
||||
|
||||
|
||||
class BIM_PT_patch(bpy.types.Panel):
|
||||
bl_label = "Patch"
|
||||
bl_idname = "BIM_PT_patch"
|
||||
@@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel):
|
||||
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
|
||||
|
||||
if props.ifc_patch_args_attr:
|
||||
preset_row = layout.row(heading="Preset", align=True)
|
||||
preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label)
|
||||
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD")
|
||||
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True
|
||||
|
||||
draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
|
||||
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -162,8 +162,8 @@ class ProjectLibraryData:
|
||||
library_file = IfcStore.library_file
|
||||
if library_file is None or library_file.schema == "IFC2X3":
|
||||
return results
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
results.append((str(project.id()), f"IfcProject {project.Name or 'Unnamed'}", project.Description or ""))
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
|
||||
for library_id, data in cls.data["project_libraries"].items():
|
||||
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
|
||||
return results
|
||||
|
||||
@@ -281,9 +281,9 @@ class RefreshLibrary(bpy.types.Operator):
|
||||
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
|
||||
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
|
||||
|
||||
ifc_project = library_file.by_type("IfcProject")[0]
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
hierarchy = tool.Project.get_project_hierarchy(library_file)
|
||||
tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy)
|
||||
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -763,7 +763,10 @@ class EditProjectLibrary(bpy.types.Operator):
|
||||
previous_parent_library = tool.Project.get_parent_library(project_library)
|
||||
new_parent_library = library_file.by_id(int(props.parent_library))
|
||||
if previous_parent_library != new_parent_library:
|
||||
if previous_parent_library.is_a("IfcProject"):
|
||||
if previous_parent_library is None:
|
||||
# Edited library was a root in a library-only file; nest it under the new parent.
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
elif previous_parent_library.is_a("IfcProject"):
|
||||
# Then new one is IfcProjectLibrary.
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
else: # Previous is IfcProjectLibrary.
|
||||
@@ -804,9 +807,12 @@ class AddProjectLibrary(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
library_file = IfcStore.library_file
|
||||
assert library_file
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], project)
|
||||
if root_context.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
|
||||
else:
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
|
||||
ProjectLibraryData.load() # Update enum.
|
||||
props.selected_project_library = str(project_library.id())
|
||||
props.is_editing_project_library = True
|
||||
@@ -1113,6 +1119,14 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
if not tool.Ifc.get().by_type("IfcProject"):
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
"This file contains no IfcProject. It is likely an IFC project library — "
|
||||
"load it via Project Setup → Project Library → Select Library File instead.",
|
||||
)
|
||||
IfcStore.purge()
|
||||
return {"CANCELLED"}
|
||||
props = tool.Project.get_project_props()
|
||||
props.is_loading = True
|
||||
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
|
||||
@@ -1236,6 +1250,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 +3564,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"}
|
||||
|
||||
@@ -98,7 +98,8 @@ def is_editing_project_library_update(self: "BIMProjectProperties", context: bpy
|
||||
project_library = library_file.by_id(int(self.selected_project_library))
|
||||
self.project_library_attributes.clear()
|
||||
bonsai.bim.helper.import_attributes(project_library, self.project_library_attributes)
|
||||
self.parent_library = str(tool.Project.get_parent_library(project_library).id())
|
||||
if parent_library := tool.Project.get_parent_library(project_library):
|
||||
self.parent_library = str(parent_library.id())
|
||||
ProjectLibraryData.load() # Show edit icon in enum.
|
||||
return
|
||||
|
||||
@@ -306,6 +307,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 +384,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 +551,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"]
|
||||
|
||||
@@ -28,9 +28,8 @@ from bpy.types import Menu, Panel, UIList
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import draw_attributes, prop_with_search
|
||||
from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectData
|
||||
from bonsai.bim.ui import draw_multiline_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.project.prop import (
|
||||
@@ -167,20 +166,6 @@ class BIM_PT_project(Panel):
|
||||
if pprops.is_loading:
|
||||
self.draw_advanced_loading_ui(context)
|
||||
elif self.file or props.ifc_file:
|
||||
if is_cache_locked_by_other_process():
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
row = box.row(align=True)
|
||||
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
|
||||
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
"This file is open in another Blender instance. Editing the same "
|
||||
"IFC from two instances at once can lose your work or display "
|
||||
"outdated geometry. Close the other Blender instances to continue safely.",
|
||||
context=context,
|
||||
)
|
||||
|
||||
if props.has_blend_warning:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
@@ -190,21 +175,6 @@ class BIM_PT_project(Panel):
|
||||
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
|
||||
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
|
||||
|
||||
if pending := pprops.pending_opening_recut:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
box.label(text="Opening Cuts Skipped", icon="ERROR")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
f"{len(pending)} element(s) had too many openings to cut during load. "
|
||||
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
|
||||
context=context,
|
||||
)
|
||||
row = box.row(align=True)
|
||||
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
|
||||
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
|
||||
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
|
||||
|
||||
if props.ifc_file:
|
||||
self.draw_loaded_project_ui(context)
|
||||
else:
|
||||
|
||||
@@ -65,10 +65,28 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if active_drawing:
|
||||
active_target_view = tool.Drawing.get_drawing_target_view(active_drawing)
|
||||
|
||||
compatible: list[tuple[bpy.types.Object, ifcopenshell.entity_instance]] = []
|
||||
skipped_classes: set[str] = set()
|
||||
for obj in related_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcObject"):
|
||||
continue
|
||||
if not tool.Type.is_relating_type_compatible(element, relating_type):
|
||||
skipped_classes.add(element.is_a())
|
||||
continue
|
||||
compatible.append((obj, element))
|
||||
|
||||
if skipped_classes:
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{relating_type.is_a()}.",
|
||||
)
|
||||
|
||||
if not compatible:
|
||||
self.report({"ERROR"}, f"No selected object can be typed by {relating_type.is_a()}.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
for obj, element in compatible:
|
||||
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=element, type=relating_type)
|
||||
|
||||
# Switch to the drawing's target view if available
|
||||
@@ -376,12 +394,22 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if self.assign_selected_objects:
|
||||
selected_objects = tool.Blender.get_selected_objects()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
skipped_classes: set[str] = set()
|
||||
for selected_obj in selected_objects:
|
||||
selected_element = tool.Ifc.get_entity(selected_obj)
|
||||
if selected_element and selected_element.is_a("IfcObject"):
|
||||
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
|
||||
if prefs.occurrence_name_style == "TYPE":
|
||||
selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
|
||||
if not selected_element or not selected_element.is_a("IfcObject"):
|
||||
continue
|
||||
if not tool.Type.is_relating_type_compatible(selected_element, new):
|
||||
skipped_classes.add(selected_element.is_a())
|
||||
continue
|
||||
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
|
||||
if prefs.occurrence_name_style == "TYPE":
|
||||
selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
|
||||
if skipped_classes:
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{new.is_a()}.",
|
||||
)
|
||||
|
||||
if obj in context.selectable_objects:
|
||||
tool.Blender.select_and_activate_single_object(context, new_obj)
|
||||
|
||||
@@ -39,6 +39,7 @@ from natsort import natsorted
|
||||
import bonsai.bim
|
||||
import bonsai.bim.helper
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import is_cache_locked_by_other_process
|
||||
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
|
||||
from bonsai.bim.module.model.prop import (
|
||||
BIMDoorProperties,
|
||||
@@ -517,6 +518,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 +816,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()
|
||||
@@ -956,6 +975,50 @@ class BIM_PT_tabs(Panel):
|
||||
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
|
||||
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
|
||||
|
||||
if is_cache_locked_by_other_process():
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
row = box.row(align=True)
|
||||
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
|
||||
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
"This file is open in another Blender instance. Editing the same "
|
||||
"IFC from two instances at once can lose your work or display "
|
||||
"outdated geometry. Close the other Blender instances to continue safely.",
|
||||
context=context,
|
||||
)
|
||||
|
||||
pprops = tool.Project.get_project_props()
|
||||
if pending := pprops.pending_opening_recut:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
box.label(text="Opening Cuts Skipped", icon="ERROR")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
f"{len(pending)} element(s) had too many openings to cut during load. "
|
||||
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
|
||||
context=context,
|
||||
)
|
||||
row = box.row(align=True)
|
||||
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
|
||||
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")
|
||||
|
||||
gprops = tool.Geometry.get_geometry_props()
|
||||
# Check that Blender mode and IFC Mode do match.
|
||||
if context.mode == "OBJECT" and gprops.mode in ("OBJECT", "ITEM"):
|
||||
@@ -1907,6 +1970,7 @@ class BIM_PT_decorators_overlay(Panel):
|
||||
aggregate_props = tool.Aggregate.get_aggregate_props()
|
||||
nest_props = tool.Nest.get_nest_props()
|
||||
model_props = tool.Model.get_model_props()
|
||||
system_props = tool.System.get_system_props()
|
||||
display_all = overlay.show_overlays
|
||||
|
||||
col = layout.column()
|
||||
@@ -1924,10 +1988,20 @@ class BIM_PT_decorators_overlay(Panel):
|
||||
row = col.row(align=True)
|
||||
row.prop(model_props, "show_slab_direction", text="Slab Direction")
|
||||
row = col.row(align=True)
|
||||
row.prop(model_props, "show_paths", text="Element Paths")
|
||||
row.prop(system_props, "should_draw_decorations", text="System Decorations")
|
||||
row = col.row(align=True)
|
||||
row.prop(model_props, "show_bounding_box", text="Bounding Box Dimensions")
|
||||
row = col.row(align=True)
|
||||
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
|
||||
row.prop(model_props, "show_cut_decorator_fill", text="Fill Cut Decorator")
|
||||
clip_box_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
row = col.row(align=True)
|
||||
# Grey out the toggles when there is no clip box to act on, so the
|
||||
# user can see the controls but can't flip a switch that does nothing.
|
||||
row.enabled = bool(clip_box_props.clip_boxes)
|
||||
row.prop(clip_box_props, "enabled", text="Enable Clipping")
|
||||
row.prop(clip_box_props, "show_caps", text="Show Caps")
|
||||
|
||||
|
||||
class BIM_PT_snappping(Panel):
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Shared post-disconnect cleanup dispatch.
|
||||
|
||||
Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the
|
||||
connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit
|
||||
disconnect-on-delete). Each 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
|
||||
kind means extending one dispatch table — both call sites benefit
|
||||
automatically and the AST forward-compat guard enforces coverage.
|
||||
|
||||
The ``subject`` parameter is the entity whose teardown effects the
|
||||
disconnect: for ``"path"`` / ``"element"`` / ``"element-top"`` kinds it
|
||||
carries an ``IfcRel*`` relationship entity (the rel that gets removed);
|
||||
for ``"mep-pair-fitting"`` it carries an ``IfcFlowFitting`` (the fitting
|
||||
that gets deleted). The slot is uniform on intent — the dispatch decides
|
||||
the teardown mechanism by kind.
|
||||
"""
|
||||
|
||||
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 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],
|
||||
subject: 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 connection.
|
||||
|
||||
``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=subject)
|
||||
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(subject, elem, partner)
|
||||
ifc.run(
|
||||
"geometry.disconnect_element",
|
||||
relating_element=subject.RelatingElement,
|
||||
related_element=subject.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=subject.RelatingElement,
|
||||
related_element=subject.RelatedElement,
|
||||
)
|
||||
elif kind == "mep-pair-fitting":
|
||||
if skip_elem_recreate and subject is elem:
|
||||
return
|
||||
if skip_partner_recreate and subject is partner:
|
||||
return
|
||||
fitting_obj = ifc.get_object(subject)
|
||||
if fitting_obj is not None:
|
||||
geometry.delete_ifc_object(fitting_obj)
|
||||
else:
|
||||
raise ValueError(f"Unknown kind: {kind!r}")
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1198,6 +1208,7 @@ class Type:
|
||||
def get_representation_context(cls, representation): pass
|
||||
def get_type_occurrences(cls, element_type): pass
|
||||
def has_material_usage(cls, element): pass
|
||||
def is_relating_type_compatible(cls, occurrence, relating_type): pass
|
||||
def record_material_usage_attributes(cls, element): pass
|
||||
def restore_material_usage_attributes(cls, element, usage_attributes): pass
|
||||
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -47,9 +55,11 @@ from typing import (
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import gpu
|
||||
import ifcopenshell.util.element
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from ifcopenshell import entity_instance
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
@@ -575,6 +585,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 +1398,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
|
||||
@@ -2278,6 +2405,83 @@ class Blender(bonsai.core.tool.Blender):
|
||||
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
|
||||
draw_batch("TRIS", world_vert_coords, color, tris)
|
||||
|
||||
@classmethod
|
||||
def draw_quads(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
quads: Sequence[
|
||||
tuple[
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
]
|
||||
],
|
||||
*,
|
||||
fill_color: Optional[tuple[float, float, float, float]] = None,
|
||||
outline_color: Optional[tuple[float, float, float, float]] = None,
|
||||
outline_width: float = 1.0,
|
||||
) -> None:
|
||||
"""Render ``quads`` (each a 4-tuple of CCW world-space corners) as
|
||||
a filled TRIS batch, an outline LINES batch, or both.
|
||||
|
||||
Both colors are RGBA 4-tuples. Pass ``fill_color=None`` to skip
|
||||
the fill pass and ``outline_color=None`` to skip the outline.
|
||||
Skipping both is a no-op.
|
||||
|
||||
Replaces the per-decorator quad-fill helpers that used to live
|
||||
inline in each feature module.
|
||||
"""
|
||||
if not quads or (fill_color is None and outline_color is None):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
tri_indices: list[tuple[int, int, int]] = []
|
||||
line_indices: list[tuple[int, int]] = []
|
||||
for quad in quads:
|
||||
if len(quad) != 4:
|
||||
continue
|
||||
base = len(verts)
|
||||
verts.extend(tuple(v) for v in quad)
|
||||
if fill_color is not None:
|
||||
tri_indices.append((base, base + 1, base + 2))
|
||||
tri_indices.append((base, base + 2, base + 3))
|
||||
if outline_color is not None:
|
||||
line_indices.append((base, base + 1))
|
||||
line_indices.append((base + 1, base + 2))
|
||||
line_indices.append((base + 2, base + 3))
|
||||
line_indices.append((base + 3, base))
|
||||
|
||||
if not cls.validate_shader_batch_data(verts, None):
|
||||
return
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
try:
|
||||
if fill_color is not None and tri_indices:
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", fill_color)
|
||||
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=tri_indices)
|
||||
batch.draw(shader)
|
||||
if outline_color is not None and line_indices:
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", outline_color)
|
||||
# Outline width: the UNIFORM_COLOR shader respects the
|
||||
# GPU's current line-width state; restore on exit.
|
||||
prev_width = gpu.state.line_width_get()
|
||||
gpu.state.line_width_set(outline_width)
|
||||
try:
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=line_indices)
|
||||
batch.draw(shader)
|
||||
finally:
|
||||
gpu.state.line_width_set(prev_width)
|
||||
finally:
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
@classmethod
|
||||
def build_dashed_line_segments(
|
||||
cls,
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Generic discovery of the connection 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. Each lookup returns ``(subject, kind)`` tuples where
|
||||
``subject`` is the entity whose teardown effects the disconnect:
|
||||
|
||||
- ``"path"`` — ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.).
|
||||
``subject`` is the rel; removing it disconnects.
|
||||
- ``"element-top"`` — ``IfcRelConnectsElements`` with ``Description=="TOP"``
|
||||
(created by ``extend_walls_to_underside``). ``subject`` is the rel.
|
||||
- ``"element"`` — any other ``IfcRelConnectsElements``. ``subject`` is the rel.
|
||||
- ``"mep-pair-fitting"`` — two MEP elements joined via ``IfcRelConnectsPorts``
|
||||
through a single bridging ``IfcFlowFitting``. ``subject`` is the fitting
|
||||
itself; removing it disconnects. ``OBSTRUCTION`` fittings are excluded
|
||||
here; those go through ``bim.mep_add_obstruction(mode=REMOVE)``.
|
||||
|
||||
Add new kinds by extending :py:meth:`Connection.find_rels`. The dispatch in
|
||||
``bonsai.core.connection.disconnect_rel`` maps each kind to the right
|
||||
post-mutation cleanup; the AST forward-compat guard enforces coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
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 connection linking ``elem_a`` to ``elem_b``
|
||||
as a list of ``(subject, kind)`` tuples — ``subject`` is the entity
|
||||
whose teardown effects the disconnect (the rel itself for
|
||||
relationship-kinds, the bridging fitting for ``"mep-pair-fitting"``).
|
||||
Walks both ``ConnectedTo`` and ``ConnectedFrom`` because either side
|
||||
of a rel can be the relating element, and the same pair may carry
|
||||
rels authored with opposite orientations."""
|
||||
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)
|
||||
|
||||
fitting = tool.System.find_bridging_fitting(elem_a, elem_b)
|
||||
if fitting is not None:
|
||||
_record(fitting, "mep-pair-fitting")
|
||||
|
||||
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 ``(subject, 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 connection touching ``elem`` as
|
||||
``(subject, kind, partner)`` triples. ``partner`` is the *other*
|
||||
element on the connection — the side cascade cleanup must operate on
|
||||
when ``elem`` is being deleted.
|
||||
|
||||
Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably
|
||||
does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts``
|
||||
cleanup is owned by ``tool.Geometry.delete_ifc_object``'s
|
||||
``remove_port`` loop, which runs unconditionally on any IFC root
|
||||
deletion. Including MEP here would cause the cascade to also remove
|
||||
the bridging fitting when one of its connected segments is deleted —
|
||||
a policy choice (fitting may still join other live segments) that's
|
||||
better left to the user via the explicit disconnect operator.
|
||||
"""
|
||||
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
|
||||
@@ -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 subject, kind, partner in tool.Connection.find_rels_for_element(element):
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
subject=subject,
|
||||
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)
|
||||
|
||||
@@ -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):
|
||||
@@ -3010,6 +3058,9 @@ class Model(bonsai.core.tool.Model):
|
||||
regenerate_fillet_corner_wall(element, obj)
|
||||
return
|
||||
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
||||
if rep is None:
|
||||
# Wall has no IfcMaterialLayerSet — layer-set rebuild not applicable.
|
||||
return
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
@@ -3024,6 +3075,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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,18 +18,33 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.schema
|
||||
import ifcpatch
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.patch.prop import BIMPatchProperties
|
||||
|
||||
|
||||
# Lower index = older schema. Used to detect downgrades vs upgrades.
|
||||
_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2}
|
||||
|
||||
# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter
|
||||
# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py).
|
||||
_MIGRATE_SCHEMA_ARG_NAME = "Schema"
|
||||
|
||||
# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the
|
||||
# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier.
|
||||
_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE)
|
||||
|
||||
|
||||
class Patch(bonsai.core.tool.Patch):
|
||||
@classmethod
|
||||
def get_patch_props(cls) -> BIMPatchProperties:
|
||||
@@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch):
|
||||
"SplitByBuildingStorey",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_preset_subdir(cls) -> str:
|
||||
"""Resolve the preset subdirectory for the currently selected recipe.
|
||||
|
||||
Returns a stable string for the ``-`` placeholder so the menu and save
|
||||
operator remain usable when no real recipe has been picked yet."""
|
||||
recipe = cls.get_patch_props().ifc_patch_recipes or "-"
|
||||
return f"bonsai/ifc_patch/{recipe}"
|
||||
|
||||
@classmethod
|
||||
def migration_is_lossy_downgrade(cls) -> bool:
|
||||
"""``True`` when the currently configured patch is the ``Migrate``
|
||||
recipe targeting an older schema than the input file. Used to gate
|
||||
the destructive-migration confirmation dialog."""
|
||||
props = cls.get_patch_props()
|
||||
if props.ifc_patch_recipes != "Migrate":
|
||||
return False
|
||||
target_schema = next(
|
||||
(arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME),
|
||||
None,
|
||||
)
|
||||
if not target_schema:
|
||||
return False
|
||||
source_schema = cls._patch_source_schema()
|
||||
if not source_schema:
|
||||
return False
|
||||
return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1)
|
||||
|
||||
@classmethod
|
||||
def _patch_source_schema(cls) -> str:
|
||||
"""Resolve the IFC schema of the configured input without parsing the
|
||||
full file. For loaded-from-memory the schema is in the entity_instance
|
||||
wrapper; for disk paths we read only the STEP file header (first ~2KB)
|
||||
rather than ``ifcopenshell.open`` which parses the whole file."""
|
||||
props = cls.get_patch_props()
|
||||
if props.should_load_from_memory:
|
||||
ifc_file = bonsai.tool.Ifc.get()
|
||||
return ifc_file.schema if ifc_file else ""
|
||||
if not props.ifc_patch_input:
|
||||
return ""
|
||||
try:
|
||||
with open(props.ifc_patch_input, "rb") as f:
|
||||
header = f.read(2048).decode("utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return ""
|
||||
match = _IFC_FILE_SCHEMA_RE.search(header)
|
||||
if not match:
|
||||
return ""
|
||||
# Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their
|
||||
# base via the canonical normaliser — handles longest-prefix-first
|
||||
# ordering correctly (IFC4X3 before IFC4) so we don't misclassify
|
||||
# IFC4X3 files as IFC4.
|
||||
try:
|
||||
return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper())
|
||||
except AssertionError:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
|
||||
if recipe == "ExtractElements":
|
||||
|
||||
@@ -32,6 +32,7 @@ from typing import (
|
||||
NotRequired,
|
||||
Optional,
|
||||
TypedDict,
|
||||
Union,
|
||||
)
|
||||
|
||||
import bpy
|
||||
@@ -376,12 +377,31 @@ class Project(bonsai.core.tool.Project):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_parent_library(cls, project_library: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
||||
def get_parent_library(
|
||||
cls, project_library: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the IfcContext that declares or nests ``project_library``.
|
||||
|
||||
Returns ``None`` when ``project_library`` is itself the root of a
|
||||
library-only file (no IfcRelNests, no IfcRelDeclares).
|
||||
"""
|
||||
if nests := project_library.Nests:
|
||||
# IfcProjectLibrary.
|
||||
return nests[0].RelatingObject
|
||||
# IfcProject.
|
||||
return project_library.HasContext[0].RelatingContext
|
||||
if has_context := project_library.HasContext:
|
||||
return has_context[0].RelatingContext
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
|
||||
"""Return the file's root IfcContext.
|
||||
|
||||
Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary —
|
||||
library-only files are valid per IFC4+ and contain no IfcProject. Caller is
|
||||
responsible for the IFC2X3 guard; IfcContext does not exist in that schema.
|
||||
"""
|
||||
if projects := ifc_file.by_type("IfcProject"):
|
||||
return projects[0]
|
||||
return ifc_file.by_type("IfcProjectLibrary")[0]
|
||||
|
||||
@classmethod
|
||||
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
|
||||
@@ -401,6 +421,8 @@ class Project(bonsai.core.tool.Project):
|
||||
return hierarchy
|
||||
for project_library in ifc_file.by_type("IfcProjectLibrary"):
|
||||
parent_library = cls.get_parent_library(project_library)
|
||||
if parent_library is None:
|
||||
continue
|
||||
hierarchy[parent_library][project_library] = hierarchy[project_library]
|
||||
return hierarchy
|
||||
|
||||
|
||||
@@ -969,6 +969,17 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
def create_snap_obj(cls, obj):
|
||||
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
|
||||
return None
|
||||
# Drop cache entries whose Blender object has been deleted. Accessing any
|
||||
# attribute on a removed object raises ReferenceError ("StructRNA ... has
|
||||
# been removed"), so test validity before comparing names below.
|
||||
valid_snap_objs = []
|
||||
for snap_obj in cls.snap_objs:
|
||||
try:
|
||||
snap_obj.obj.name
|
||||
except ReferenceError:
|
||||
continue
|
||||
valid_snap_objs.append(snap_obj)
|
||||
cls.snap_objs[:] = valid_snap_objs
|
||||
for i, snap_obj in enumerate(cls.snap_objs):
|
||||
if obj.name == snap_obj.obj.name:
|
||||
# Handle objects modified while a modal operator is active.
|
||||
|
||||
@@ -488,6 +488,69 @@ class System(bonsai.core.tool.System):
|
||||
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
|
||||
|
||||
@classmethod
|
||||
def is_disconnectable_fitting(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""A fitting whose deletion is the supported teardown for one of
|
||||
its port connections. ``OBSTRUCTION`` fittings are excluded — they
|
||||
have a dedicated grow/shrink flow (``bim.mep_add_obstruction``
|
||||
with ``mode=REMOVE``) that absorbs the freed segment length."""
|
||||
if not element.is_a("IfcFlowFitting"):
|
||||
return False
|
||||
return getattr(element, "PredefinedType", None) != "OBSTRUCTION"
|
||||
|
||||
@classmethod
|
||||
def neighbours_at_ports(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||
"""Entities reachable from ``element``'s ports via a single
|
||||
``IfcRelConnectsPorts`` hop, deduped by IFC id."""
|
||||
neighbours: list[ifcopenshell.entity_instance] = []
|
||||
seen: set[int] = set()
|
||||
for port in cls.get_ports(element):
|
||||
connected_port = cls.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
neighbour = ifcopenshell.util.system.get_port_element(connected_port)
|
||||
if neighbour is None or neighbour.id() in seen:
|
||||
continue
|
||||
seen.add(neighbour.id())
|
||||
neighbours.append(neighbour)
|
||||
return neighbours
|
||||
|
||||
@classmethod
|
||||
def find_bridging_fitting(
|
||||
cls,
|
||||
elem_a: ifcopenshell.entity_instance,
|
||||
elem_b: ifcopenshell.entity_instance,
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the disconnectable ``IfcFlowFitting`` whose removal
|
||||
disconnects ``elem_a`` from ``elem_b``, or ``None``.
|
||||
|
||||
Two topologies are handled. (1) Direct port-to-port between a
|
||||
segment/fitting and a disconnectable fitting: the fitting endpoint
|
||||
is returned. (2) Two segments joined by a single bridging
|
||||
disconnectable fitting: the bridging fitting is returned.
|
||||
``OBSTRUCTION`` fittings short-circuit to ``None``."""
|
||||
if not (cls.is_mep_element(elem_a) and cls.is_mep_element(elem_b)):
|
||||
return None
|
||||
|
||||
a_neighbours = cls.neighbours_at_ports(elem_a)
|
||||
b_neighbours = cls.neighbours_at_ports(elem_b)
|
||||
elem_a_id = elem_a.id()
|
||||
elem_b_id = elem_b.id()
|
||||
|
||||
if cls.is_disconnectable_fitting(elem_a) and any(n.id() == elem_b_id for n in a_neighbours):
|
||||
return elem_a
|
||||
if cls.is_disconnectable_fitting(elem_b) and any(n.id() == elem_a_id for n in b_neighbours):
|
||||
return elem_b
|
||||
|
||||
a_fittings = [n for n in a_neighbours if cls.is_disconnectable_fitting(n)]
|
||||
if not a_fittings:
|
||||
return None
|
||||
b_fitting_ids = {n.id() for n in b_neighbours if cls.is_disconnectable_fitting(n)}
|
||||
for fitting in a_fittings:
|
||||
if fitting.id() in b_fitting_ids:
|
||||
return fitting
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when the MEP element's body representation is a profile sweep
|
||||
|
||||
@@ -24,6 +24,7 @@ import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.type
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.tool
|
||||
@@ -96,6 +97,18 @@ class Type(bonsai.core.tool.Type):
|
||||
def get_type_occurrences(cls, element_type: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||
return ifcopenshell.util.element.get_types(element_type)
|
||||
|
||||
@classmethod
|
||||
def is_relating_type_compatible(
|
||||
cls,
|
||||
occurrence: ifcopenshell.entity_instance,
|
||||
relating_type: ifcopenshell.entity_instance,
|
||||
) -> bool:
|
||||
# IFC's EXPRESS schema has no WHERE rule pairing IfcRelDefinesByType's
|
||||
# RelatingType / RelatedObjects classes; the one-to-one class pairing
|
||||
# is a buildingSMART implementer agreement, not file-validation.
|
||||
schema = occurrence.file.schema
|
||||
return relating_type.is_a() in ifcopenshell.util.type.get_applicable_types(occurrence.is_a(), schema=schema)
|
||||
|
||||
@classmethod
|
||||
def has_material_usage(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ markers =
|
||||
brick
|
||||
bsdd
|
||||
classification
|
||||
clip_box
|
||||
context
|
||||
cost
|
||||
covering
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakePropsBase:
|
||||
"""Base for parametric-edit PropertyGroup stand-ins used in lifecycle tests.
|
||||
|
||||
The parametric-edit lifecycle mixins read/write a common contract:
|
||||
``is_editing`` (bool), ``last_kwargs`` (dict | None — capture of the last
|
||||
data written via ``set_props_kwargs_from_ifc_data``),
|
||||
``set_props_kwargs_from_ifc_data(data)``, and
|
||||
``get_general_kwargs(convert_to_project_units=True)``. Per-type stand-ins
|
||||
(door, railing, roof) subclass this and add their own kwargs accessors
|
||||
and per-type fields."""
|
||||
|
||||
def __init__(self, general: dict | None = None):
|
||||
self.is_editing = False
|
||||
self.last_kwargs: dict | None = None
|
||||
self.general = dict(general) if general is not None else {}
|
||||
|
||||
def set_props_kwargs_from_ifc_data(self, data):
|
||||
self.last_kwargs = dict(data)
|
||||
|
||||
def get_general_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.general)
|
||||
|
||||
|
||||
def make_lifecycle_obj(props, *, name="obj"):
|
||||
"""Build a ``bpy.types.Object`` stand-in for parametric-lifecycle tests.
|
||||
|
||||
The mixin code under test reads ``obj.props`` (the PropertyGroup
|
||||
stand-in) and ``obj.name`` (used in error reports). ``spec=bpy.types.Object``
|
||||
catches typo'd attribute access at test time. ``bpy`` is imported inside
|
||||
the function so this conftest stays importable when bpy is absent."""
|
||||
from unittest import mock
|
||||
|
||||
import bpy
|
||||
|
||||
obj = mock.Mock(spec=bpy.types.Object, name=name)
|
||||
obj.props = props
|
||||
obj.name = name
|
||||
return obj
|
||||
|
||||
|
||||
# pytest by default doesn't print steps and where it failed. Let's fix that.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.spatial
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0):
|
||||
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
|
||||
obj = bpy.context.active_object
|
||||
entity = ifc.create_entity(ifc_class)
|
||||
tool.Ifc.link(entity, obj)
|
||||
return entity, obj
|
||||
|
||||
|
||||
class TestAddClipBoxForSourceSpatial(NewFile):
|
||||
def test_spatial_creates_clip_box_sized_to_contained_walls(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
storey = ifc.create_entity("IfcBuildingStorey")
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
|
||||
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
|
||||
|
||||
result = bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=str(storey.id()))
|
||||
|
||||
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 tool.ClipBox.get_object_props(host).is_clip_box is True
|
||||
translation, _, scale = host.matrix_world.decompose()
|
||||
assert translation.x == pytest.approx(2.0)
|
||||
assert scale.x == pytest.approx(3.0)
|
||||
|
||||
|
||||
class TestAddClipBoxForSourceClass(NewFile):
|
||||
def test_class_creates_clip_box_for_all_walls(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
# Two walls + one window; the IfcWall pick should cover only the walls.
|
||||
_make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
_make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
|
||||
_make_ifc_cube(ifc, "IfcWindow", location=(20.0, 0.0, 0.0), size=2.0)
|
||||
|
||||
result = bpy.ops.bim.add_clip_box_for_source(source_kind="CLASS", source_id="IfcWall")
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
host = scene_props.clip_boxes[0].obj
|
||||
translation, _, scale = host.matrix_world.decompose()
|
||||
# AABB of the two walls only (x in [-1, 5]); window at x=20 must not contribute.
|
||||
assert translation.x == pytest.approx(2.0)
|
||||
assert scale.x == pytest.approx(3.0)
|
||||
|
||||
|
||||
class TestAddClipBoxForSourceEmpty(NewFile):
|
||||
def test_no_matching_elements_reports_error(self):
|
||||
# bpy.ops.* raises RuntimeError when an operator reports {"ERROR"},
|
||||
# so the assertion is on the raised message rather than the return code.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
walltype = ifc.create_entity("IfcWallType")
|
||||
# No occurrences linked — TYPE source resolves to 0 elements.
|
||||
with pytest.raises(RuntimeError, match="No elements found"):
|
||||
bpy.ops.bim.add_clip_box_for_source(source_kind="TYPE", source_id=str(walltype.id()))
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 0
|
||||
|
||||
def test_placeholder_source_id_reports_error(self):
|
||||
# With no IFC file loaded, data.py callbacks return the NO_OPTIONS_ID
|
||||
# sentinel. Submitting that sentinel as the picked source must ERROR.
|
||||
from bonsai.bim.module.clip_box import data as clip_data
|
||||
|
||||
with pytest.raises(RuntimeError, match="No source selected"):
|
||||
bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=clip_data.NO_OPTIONS_ID)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 0
|
||||
|
||||
|
||||
class TestRemoveClipBoxOrphan(NewFile):
|
||||
def test_remove_orphan_entry_when_host_object_deleted(self):
|
||||
# The remove operator must work on an orphan entry — i.e. one whose
|
||||
# host empty was deleted out from under it via the outliner.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
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
|
||||
|
||||
bpy.data.objects.remove(host, do_unlink=True)
|
||||
|
||||
# Entry survives but its `obj` pointer is now None.
|
||||
assert len(scene_props.clip_boxes) == 1
|
||||
assert scene_props.clip_boxes[0].obj is None
|
||||
|
||||
result = bpy.ops.bim.remove_clip_box(index=0)
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert len(scene_props.clip_boxes) == 0
|
||||
@@ -0,0 +1,800 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import math
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
class TestAddClipBox(NewFile):
|
||||
def test_creates_empty_and_registers_entry(self):
|
||||
result = bpy.ops.bim.add_clip_box()
|
||||
assert result == {"FINISHED"}
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 1
|
||||
host = scene_props.clip_boxes[0].obj
|
||||
assert host is not None
|
||||
assert host.empty_display_type == "CUBE"
|
||||
obj_props = tool.ClipBox.get_object_props(host)
|
||||
assert obj_props.is_clip_box is True
|
||||
|
||||
def test_spawns_at_3d_cursor(self):
|
||||
bpy.context.scene.cursor.location = (4.0, 0.0, 2.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
assert host is not None
|
||||
assert host.matrix_world.translation.x == pytest.approx(4.0)
|
||||
assert host.matrix_world.translation.z == pytest.approx(2.0)
|
||||
|
||||
def test_spawns_in_clip_boxes_collection(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
collection_names = [c.name for c in host.users_collection]
|
||||
assert "BBIM_ClipBoxes" in collection_names
|
||||
|
||||
|
||||
class TestActiveClipBoxResolution(NewFile):
|
||||
def test_no_box_returns_none(self):
|
||||
assert tool.ClipBox.get_active_clip_box() is None
|
||||
|
||||
def test_active_index_out_of_range_returns_none(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.active_clip_box_index = 99
|
||||
assert tool.ClipBox.get_active_clip_box() is None
|
||||
|
||||
|
||||
class TestComputePlanes(NewFile):
|
||||
def test_planes_match_unit_box_at_origin(self):
|
||||
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Identity(4)
|
||||
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, -2)))
|
||||
|
||||
def test_scaled_host_grows_clip_region(self):
|
||||
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
|
||||
|
||||
# Test points well clear of any reasonable expand margin so the
|
||||
# assertion pins the OBB scaling behaviour, not the margin value.
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((2.5, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((4.0, 0, 0)))
|
||||
|
||||
def test_rotated_host_rotates_clip_region(self):
|
||||
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z")
|
||||
|
||||
# Test points well clear of the expand margin so the assertion
|
||||
# pins rotation, not the margin value.
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0.5, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2.0, 0, 0)))
|
||||
|
||||
def test_translated_and_rotated_host_keeps_centre_inside(self):
|
||||
bpy.context.scene.cursor.location = (5.0, 7.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Translation((5.0, 7.0, 0.0)) @ Matrix.Rotation(math.radians(30), 4, "Z")
|
||||
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0)))
|
||||
|
||||
def test_margin_grows_with_scale(self):
|
||||
# The empty's CUBE display lives at local +-1; the GPU dot
|
||||
# product that tests each wireframe vertex against the clip
|
||||
# planes has float error that scales with the axis's world
|
||||
# half-extent. A fixed absolute margin gets eaten by that drift
|
||||
# once the box is spawned at non-trivial scale, so the half-
|
||||
# extent the planes encode must include a relative term — the
|
||||
# margin between the wireframe edge and the plane must grow
|
||||
# with the scale.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
|
||||
host.matrix_world = Matrix.Identity(4)
|
||||
planes_unit = tool.ClipBox.compute_planes(host)
|
||||
# +X plane: normal (-1, 0, 0), d = half_x. Read half from d.
|
||||
half_unit = planes_unit[0][3]
|
||||
margin_unit = half_unit - 1.0
|
||||
|
||||
host.matrix_world = Matrix.Diagonal((100.0, 100.0, 100.0, 1.0))
|
||||
planes_scaled = tool.ClipBox.compute_planes(host)
|
||||
half_scaled = planes_scaled[0][3]
|
||||
margin_scaled = half_scaled - 100.0
|
||||
|
||||
assert margin_scaled > margin_unit * 10, (
|
||||
f"margin must scale with extent: unit={margin_unit:g}, " f"scale-100={margin_scaled:g}"
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class TestClipBbReArmTriggers(NewFile):
|
||||
"""The C-side clip_bb captured by view3d.clip_border for edit-mode
|
||||
click-select is view-aligned and tied to the box pose at arm time,
|
||||
so it goes stale on either a clip-box transform commit, an external
|
||||
matrix mutation, or an IFC reload that rehydrates from pset. The
|
||||
depsgraph handler schedules a full re-arm at those events; the
|
||||
modal gate suppresses per-tick re-arms during a live drag.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
tool.ClipBox._persisted_matrices.clear()
|
||||
tool.ClipBox._last_seen_ifc_id = 0
|
||||
tool.ClipBox._refresh_pending = False
|
||||
|
||||
def teardown_method(self):
|
||||
tool.ClipBox._persisted_matrices.clear()
|
||||
tool.ClipBox._last_seen_ifc_id = 0
|
||||
tool.ClipBox._refresh_pending = False
|
||||
|
||||
def test_matrix_change_outside_modal_re_arms(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
# Seed a stale baseline so prev_matrix != current_matrix.
|
||||
stale = tuple(tuple(row) for row in Matrix.Translation((-99.0, 0.0, 0.0)))
|
||||
tool.ClipBox._persisted_matrices[host.name] = stale
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
|
||||
):
|
||||
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
def test_matrix_change_during_modal_skips_re_arm(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
stale = tuple(tuple(row) for row in Matrix.Translation((-99.0, 0.0, 0.0)))
|
||||
tool.ClipBox._persisted_matrices[host.name] = stale
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=True),
|
||||
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
|
||||
):
|
||||
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
def test_first_matrix_sighting_does_not_re_arm(self):
|
||||
# No prior persisted-matrix entry: the branch records the
|
||||
# baseline and exits without re-arming. The add path already
|
||||
# armed once; a per-tick re-arm on first sight would double-arm.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
tool.ClipBox._persisted_matrices.pop(host.name, None)
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
|
||||
):
|
||||
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
def test_ifc_reload_re_arms(self):
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
# Force an ifc-id mismatch so the rehydrate-from-pset branch
|
||||
# fires. The .blend carries the prior session's clip_bb forward;
|
||||
# the picker is armed for the OLD view until this re-arms.
|
||||
tool.ClipBox._last_seen_ifc_id = 0
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
|
||||
):
|
||||
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
|
||||
# IFC-load triggers a re-arm. (Reading the show_caps pset entry
|
||||
# writes scene_props.show_caps via its update callback, which
|
||||
# is a separate pre-existing re-arm path; the test pins the
|
||||
# invariant "ifc-load arms at least once".)
|
||||
assert mock_refresh.call_count >= 1
|
||||
@@ -0,0 +1,273 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the ``clip_only_ifc_products`` toggle contract.
|
||||
|
||||
The toggle gates the cap-eligibility filter (IFC-only vs. all visible meshes)
|
||||
and lives only on the Blender Scene PG — the project pset must never carry it.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
def _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)):
|
||||
bpy.ops.mesh.primitive_cube_add(size=2.0, location=location)
|
||||
obj = bpy.context.active_object
|
||||
entity = ifc.create_entity("IfcWall")
|
||||
tool.Ifc.link(entity, obj)
|
||||
return entity, obj
|
||||
|
||||
|
||||
def _make_blender_cube(location=(0.0, 0.0, 0.0)):
|
||||
bpy.ops.mesh.primitive_cube_add(size=2.0, location=location)
|
||||
return bpy.context.active_object
|
||||
|
||||
|
||||
class TestDefaultIsTrue(NewFile):
|
||||
def test_clip_only_ifc_products_defaults_to_true(self):
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert scene_props.clip_only_ifc_products is True
|
||||
|
||||
|
||||
class TestCapEligibilityHonorsToggle(NewFile):
|
||||
def test_only_ifc_true_excludes_non_ifc_mesh(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
_, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0))
|
||||
cube = _make_blender_cube(location=(4.0, 0.0, 0.0))
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.clip_only_ifc_products = True
|
||||
|
||||
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
|
||||
|
||||
assert wall in eligible
|
||||
assert cube not in eligible
|
||||
|
||||
def test_only_ifc_false_includes_non_ifc_mesh(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
_, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0))
|
||||
cube = _make_blender_cube(location=(4.0, 0.0, 0.0))
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.clip_only_ifc_products = False
|
||||
|
||||
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
|
||||
|
||||
assert wall in eligible
|
||||
assert cube in eligible
|
||||
|
||||
def test_only_ifc_false_works_without_ifc_file_loaded(self):
|
||||
# No IFC at all; eligibility should still yield Blender meshes when
|
||||
# the IFC-only filter is off, since there's nothing to filter against.
|
||||
cube = _make_blender_cube(location=(0.0, 0.0, 0.0))
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.clip_only_ifc_products = False
|
||||
|
||||
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
|
||||
|
||||
assert cube in eligible
|
||||
|
||||
|
||||
class TestShowCapsTriggersRebuild(NewFile):
|
||||
def test_show_caps_off_then_on_schedules_cap_rebuild(self):
|
||||
# Off → On must schedule a rebuild — without this, caps stay empty
|
||||
# until the user nudges geometry to fire the next depsgraph tick.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.show_caps = False
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
assert tool.ClipBox._pending_cap_rebuild is None
|
||||
|
||||
scene_props.show_caps = True
|
||||
|
||||
assert tool.ClipBox._pending_cap_rebuild is not None
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
|
||||
class TestRebuildCapsNow(NewFile):
|
||||
def test_rebuild_caps_now_cancels_any_pending_debounce(self):
|
||||
# Synchronous path must wipe the debounced timer — otherwise the
|
||||
# rebuild fires twice when the gizmo unlock interleaves with a
|
||||
# depsgraph tick.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
tool.ClipBox._schedule_cap_rebuild()
|
||||
assert tool.ClipBox._pending_cap_rebuild is not None
|
||||
|
||||
tool.ClipBox.rebuild_caps_now()
|
||||
|
||||
assert tool.ClipBox._pending_cap_rebuild is None
|
||||
|
||||
|
||||
class TestActiveClipBoxIndexRebuildsCaps(NewFile):
|
||||
def test_index_change_schedules_cap_rebuild(self):
|
||||
# UI-list click changes active_clip_box_index — the cap cache
|
||||
# belongs to the previous box's clip volume, so a rebuild must
|
||||
# be scheduled so the overlay matches the newly-active box.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
assert tool.ClipBox._pending_cap_rebuild is None
|
||||
|
||||
scene_props.active_clip_box_index = 0
|
||||
|
||||
assert tool.ClipBox._pending_cap_rebuild is not None
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
|
||||
def _exec_align_view(axis: int, is_max: bool):
|
||||
"""Run ``bim.align_view_to_clip_face`` against the first VIEW_3D area
|
||||
and return its ``rv3d``. Skips if no viewport is available in the
|
||||
test session."""
|
||||
for area in bpy.context.window.screen.areas:
|
||||
if area.type != "VIEW_3D":
|
||||
continue
|
||||
region = next((r for r in area.regions if r.type == "WINDOW"), None)
|
||||
if region is None:
|
||||
continue
|
||||
with bpy.context.temp_override(area=area, region=region):
|
||||
result = bpy.ops.bim.align_view_to_clip_face("EXEC_DEFAULT", axis=axis, is_max=is_max)
|
||||
assert result == {"FINISHED"}
|
||||
return bpy.context.space_data.region_3d
|
||||
pytest.skip("No VIEW_3D area available")
|
||||
|
||||
|
||||
class TestAlignViewToClipFace(NewFile):
|
||||
def test_align_view_sets_rv3d_rotation_to_face_normal(self):
|
||||
# The operator must reorient the viewport so its forward axis
|
||||
# points AGAINST the picked face's outward normal (so the user
|
||||
# sees the face from outside).
|
||||
bpy.ops.bim.add_clip_box()
|
||||
clip_box = tool.ClipBox.get_active_clip_box()
|
||||
# Rotate the empty so the +X face's outward world normal isn't
|
||||
# axis-aligned — proves the operator handles arbitrary rotation.
|
||||
clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "Z") @ clip_box.matrix_world
|
||||
|
||||
rv3d = _exec_align_view(axis=0, is_max=True)
|
||||
|
||||
outward = clip_box.matrix_world.to_3x3().col[0].normalized()
|
||||
forward = rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))
|
||||
assert (forward - (-outward)).length < 1e-4
|
||||
|
||||
def test_align_view_uses_box_local_z_up_for_side_face(self):
|
||||
# Side faces (±X, ±Y local normals) follow Blender's numpad 1 / 3
|
||||
# convention but in the BOX'S local frame: local +Z is the
|
||||
# screen-up axis, transformed through the empty's rotation.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
clip_box = tool.ClipBox.get_active_clip_box()
|
||||
clip_box.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") @ clip_box.matrix_world
|
||||
|
||||
rv3d = _exec_align_view(axis=0, is_max=True)
|
||||
|
||||
expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized()
|
||||
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
|
||||
assert (
|
||||
up_world - expected_up
|
||||
).length < 1e-3, (
|
||||
f"Side-face view must have box-local +Z as up; expected {tuple(expected_up)}, got {tuple(up_world)}"
|
||||
)
|
||||
|
||||
def test_align_view_keeps_box_local_z_up_for_negative_y_face(self):
|
||||
# Clicking the -Y face used to put world +Z at the BOTTOM of the
|
||||
# screen. With box-local convention it stays at the top.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
|
||||
rv3d = _exec_align_view(axis=1, is_max=False)
|
||||
|
||||
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
|
||||
assert up_world.z > 0.99, f"-Y face view must keep box-local +Z as up, got {tuple(up_world)}"
|
||||
|
||||
def test_align_view_respects_box_local_axes_when_box_x_rotated(self):
|
||||
# Rotating around X moves box-local +Z away from world +Z; the
|
||||
# up axis must follow the BOX, otherwise the box edges no longer
|
||||
# appear horizontal/vertical when aligned to a face — the bug
|
||||
# users hit on rotated boxes.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
clip_box = tool.ClipBox.get_active_clip_box()
|
||||
clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "X") @ clip_box.matrix_world
|
||||
|
||||
rv3d = _exec_align_view(axis=0, is_max=True)
|
||||
|
||||
expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized()
|
||||
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
|
||||
assert (
|
||||
up_world - expected_up
|
||||
).length < 1e-3, f"X-rotated box must use box-local Z; expected {tuple(expected_up)}, got {tuple(up_world)}"
|
||||
|
||||
def test_align_view_uses_box_local_y_up_for_top_face(self):
|
||||
# Top face (local +Z outward) follows Blender's numpad-7
|
||||
# convention applied in the box's local frame: local +Y is up.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
|
||||
rv3d = _exec_align_view(axis=2, is_max=True)
|
||||
|
||||
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
|
||||
assert up_world.y > 0.99, f"Top-face view must have box-local +Y as up, got {tuple(up_world)}"
|
||||
|
||||
def test_align_view_uses_box_local_negative_y_up_for_bottom_face(self):
|
||||
# Bottom face (local -Z outward) follows ctrl-numpad-7: box-local
|
||||
# -Y is up.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
|
||||
rv3d = _exec_align_view(axis=2, is_max=False)
|
||||
|
||||
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
|
||||
assert up_world.y < -0.99, f"Bottom-face view must have box-local -Y as up, got {tuple(up_world)}"
|
||||
|
||||
|
||||
class TestNotPersistedToProjectPset(NewFile):
|
||||
def test_pset_does_not_carry_clip_only_ifc_products(self):
|
||||
bpy.ops.bim.create_project()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
# Flip to a non-default value, then trigger a pset write.
|
||||
scene_props.clip_only_ifc_products = False
|
||||
bpy.ops.bim.add_clip_box() # writes the pset
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
project = tool.Ifc.get().by_type("IfcProject")[0]
|
||||
pset = ifcopenshell.util.element.get_psets(project).get(tool.ClipBox.PSET_NAME, {})
|
||||
|
||||
# Whatever the pset stores, it must not carry this scene-only toggle.
|
||||
for key in pset:
|
||||
assert (
|
||||
"clip_only_ifc" not in key.lower()
|
||||
), f"Project pset unexpectedly carries the scene-only toggle (key {key!r})"
|
||||
|
||||
def test_load_from_pset_does_not_touch_clip_only_ifc_products(self):
|
||||
# Round-trip: set the toggle on the Scene, simulate a pset load, and
|
||||
# confirm the loader didn't overwrite the user's Scene-level choice.
|
||||
bpy.ops.bim.create_project()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.clip_only_ifc_products = False
|
||||
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
|
||||
assert scene_props.clip_only_ifc_products is False
|
||||
@@ -0,0 +1,294 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for the generic face-quad gizmo core.
|
||||
|
||||
Pins the three contracts the layout helper depends on:
|
||||
|
||||
* ``compute_face_resize`` — pure one-sided resize arithmetic.
|
||||
* ``front_facing_face_mask`` — view-aware face visibility predicate.
|
||||
* ``apply_face_quad_layout`` — front-facing faces upload the solid
|
||||
unit quad ("solid" state); back-facing faces upload the halo strips
|
||||
("strips" state).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from bonsai.bim.module.clip_box import face_quad
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- compute_face_resize ---
|
||||
|
||||
|
||||
class TestComputeFaceResize:
|
||||
def test_outward_drag_on_max_face_grows_half_extent_and_shifts_origin(self):
|
||||
# Pulling the +X face outward by 2.0 world units must:
|
||||
# - grow the world half by half the cursor delta (one-sided);
|
||||
# - shift the empty's origin so the opposite (-X) face stays put.
|
||||
new_scale, new_loc = face_quad.compute_face_resize(
|
||||
value=10.0 + 2.0, # init + delta
|
||||
init_world_half=10.0,
|
||||
init_location=(0.0, 0.0, 0.0),
|
||||
world_axis=(1.0, 0.0, 0.0),
|
||||
display_size=1.0,
|
||||
)
|
||||
# half-extent: 10 + 2/2 = 11
|
||||
assert new_scale == pytest.approx(11.0)
|
||||
# origin shifts by half the realized delta (= 1.0) along +X
|
||||
assert new_loc[0] == pytest.approx(1.0)
|
||||
assert new_loc[1] == pytest.approx(0.0)
|
||||
assert new_loc[2] == pytest.approx(0.0)
|
||||
|
||||
def test_inward_drag_clamps_at_minimum_half_extent(self):
|
||||
# Pulling the face inward by more than the current half collapses
|
||||
# to a tiny floor instead of going negative. The realized delta
|
||||
# (post-clamp) drives the location shift so the opposite face
|
||||
# stays fixed even at the clamp.
|
||||
new_scale, new_loc = face_quad.compute_face_resize(
|
||||
value=0.0, # delta = -1.0
|
||||
init_world_half=1.0,
|
||||
init_location=(5.0, 0.0, 0.0),
|
||||
world_axis=(1.0, 0.0, 0.0),
|
||||
display_size=1.0,
|
||||
)
|
||||
assert new_scale > 0.0
|
||||
assert new_scale < 1.0
|
||||
# New origin sits between init (5.0) and -X face (which is at 4.0
|
||||
# = init.x - init_world_half). Since the clamp limited shrinkage,
|
||||
# the new origin is just slightly less than init.x.
|
||||
assert 4.0 < new_loc[0] < 5.0
|
||||
|
||||
def test_drag_on_min_face_via_negative_world_axis_grows_outward(self):
|
||||
# On the -X face, ``world_axis`` is (-1, 0, 0). A positive
|
||||
# ``delta`` (outward on this face) must still grow the half
|
||||
# extent and shift the origin in the -X direction.
|
||||
new_scale, new_loc = face_quad.compute_face_resize(
|
||||
value=10.0 + 2.0,
|
||||
init_world_half=10.0,
|
||||
init_location=(0.0, 0.0, 0.0),
|
||||
world_axis=(-1.0, 0.0, 0.0),
|
||||
display_size=1.0,
|
||||
)
|
||||
assert new_scale == pytest.approx(11.0)
|
||||
# Origin shifts toward -X.
|
||||
assert new_loc[0] == pytest.approx(-1.0)
|
||||
|
||||
def test_display_size_scales_the_resulting_scale_axis(self):
|
||||
# The returned scale is half_extent / display_size — so a
|
||||
# display_size of 2.0 halves the scale relative to display_size
|
||||
# of 1.0 for the same world half-extent.
|
||||
new_scale_1, _ = face_quad.compute_face_resize(
|
||||
value=10.0,
|
||||
init_world_half=10.0,
|
||||
init_location=(0.0, 0.0, 0.0),
|
||||
world_axis=(1.0, 0.0, 0.0),
|
||||
display_size=1.0,
|
||||
)
|
||||
new_scale_2, _ = face_quad.compute_face_resize(
|
||||
value=10.0,
|
||||
init_world_half=10.0,
|
||||
init_location=(0.0, 0.0, 0.0),
|
||||
world_axis=(1.0, 0.0, 0.0),
|
||||
display_size=2.0,
|
||||
)
|
||||
assert new_scale_1 == pytest.approx(10.0)
|
||||
assert new_scale_2 == pytest.approx(5.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- front_facing_face_mask ---
|
||||
|
||||
|
||||
class TestFrontFacingFaceMask:
|
||||
def test_view_along_neg_z_lights_up_only_pos_z_face(self):
|
||||
# Camera looking down -Z (typical default front view): only the
|
||||
# +Z face (last entry) faces the camera.
|
||||
normals = (
|
||||
(-1.0, 0.0, 0.0), # -X face
|
||||
(1.0, 0.0, 0.0), # +X face
|
||||
(0.0, -1.0, 0.0), # -Y face
|
||||
(0.0, 1.0, 0.0), # +Y face
|
||||
(0.0, 0.0, -1.0), # -Z face
|
||||
(0.0, 0.0, 1.0), # +Z face
|
||||
)
|
||||
view_dir = (0.0, 0.0, -1.0)
|
||||
|
||||
mask = face_quad.front_facing_face_mask(normals, view_dir)
|
||||
|
||||
assert mask == (False, False, False, False, False, True)
|
||||
|
||||
def test_view_along_pos_x_lights_up_neg_x_face(self):
|
||||
# Camera looking along +X (front of the -X face).
|
||||
normals = (
|
||||
(-1.0, 0.0, 0.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, -1.0, 0.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(0.0, 0.0, -1.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
)
|
||||
view_dir = (1.0, 0.0, 0.0)
|
||||
|
||||
mask = face_quad.front_facing_face_mask(normals, view_dir)
|
||||
|
||||
assert mask == (True, False, False, False, False, False)
|
||||
|
||||
def test_wrong_length_raises(self):
|
||||
with pytest.raises(ValueError, match="expected 6 face normals"):
|
||||
face_quad.front_facing_face_mask([(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)], (0.0, 0.0, -1.0))
|
||||
|
||||
|
||||
# ----------------------------------------------------- apply_face_quad_layout (front/back) ---
|
||||
|
||||
|
||||
class _FakeQuad:
|
||||
"""Stand-in for ``BIM_GT_box_face_quad`` — only the slots the layout helper writes."""
|
||||
|
||||
def __init__(self):
|
||||
self.matrix_basis = Matrix.Identity(4)
|
||||
self.axis = Vector((0.0, 0.0, 0.0))
|
||||
self.hide = False
|
||||
self.select_bias = 0.0
|
||||
self.is_highlight = False
|
||||
self.custom_shape = None
|
||||
self.custom_shape_select = None
|
||||
self._last_geometry_state = None
|
||||
self._strips_cache_key = None
|
||||
|
||||
def new_custom_shape(self, kind, verts):
|
||||
# Layout helper only stores the result; nothing further is asked of it.
|
||||
return (kind, tuple(tuple(v) for v in verts))
|
||||
|
||||
|
||||
class _FakeOutline:
|
||||
def __init__(self):
|
||||
self.matrix_basis = Matrix.Identity(4)
|
||||
self.alpha = 0.0
|
||||
self.alpha_highlight = 0.0
|
||||
|
||||
|
||||
class _FakeRV3D:
|
||||
def __init__(self, view_rotation, view_matrix):
|
||||
self.view_rotation = view_rotation
|
||||
self.view_matrix = view_matrix
|
||||
# Blender's location_3d_to_region_2d reads perspective_matrix to
|
||||
# project world points; a simple ortho-projection matrix is enough
|
||||
# for the layout helper's halo-strip pixel measurement.
|
||||
self.perspective_matrix = view_matrix
|
||||
self.is_perspective = False
|
||||
|
||||
|
||||
class _FakeRegion:
|
||||
width = 800
|
||||
height = 600
|
||||
|
||||
|
||||
def _run_layout(view_dir: Vector) -> tuple[str, ...]:
|
||||
"""Apply the layout helper for a unit cube at the origin with a
|
||||
given world-space view direction; return each route's
|
||||
``_last_geometry_state`` in :data:`FACE_ROUTES` order."""
|
||||
quads = [_FakeQuad() for _ in range(6)]
|
||||
outlines = [_FakeOutline() for _ in range(6)]
|
||||
# view_rotation is the quaternion that rotates the camera's local
|
||||
# forward (-Z) onto the desired world view direction.
|
||||
view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(view_dir.normalized())
|
||||
rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4))
|
||||
face_quad.apply_face_quad_layout(
|
||||
quad_gizmos=quads,
|
||||
outline_gizmos=outlines,
|
||||
bmin=Vector((-1.0, -1.0, -1.0)),
|
||||
bmax=Vector((1.0, 1.0, 1.0)),
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cage_rotation=Matrix.Identity(4),
|
||||
region=_FakeRegion(),
|
||||
rv3d=rv3d,
|
||||
locked=False,
|
||||
)
|
||||
return tuple(getattr(q, "_last_geometry_state", None) for q in quads)
|
||||
|
||||
|
||||
class TestApplyFaceQuadLayout:
|
||||
def test_oblique_view_yields_solid_fronts_and_strips_or_empty_backs(self):
|
||||
# Oblique view direction (1, 1, -1) hits the box from the +X, +Y,
|
||||
# +Z octant. Faces facing toward the camera (-X, -Y, +Z) must
|
||||
# render as "solid"; faces facing away (+X, +Y, -Z) must render
|
||||
# as back-facing — either "strips" (when adjacent front faces
|
||||
# give halo edges) or "empty" (when no front-facing neighbour).
|
||||
states = _run_layout(view_dir=Vector((1.0, 1.0, -1.0)))
|
||||
|
||||
# FACE_ROUTES order: (-X, +X, -Y, +Y, -Z, +Z)
|
||||
# Front-facing routes (against the view direction): -X, -Y, +Z
|
||||
assert states[0] == "solid" # -X
|
||||
assert states[2] == "solid" # -Y
|
||||
assert states[5] == "solid" # +Z
|
||||
# Back-facing routes (with the view direction): +X, +Y, -Z
|
||||
for back_idx in (1, 3, 4):
|
||||
assert states[back_idx] in ("strips", "empty")
|
||||
|
||||
def test_negative_scale_host_does_not_invert_front_back_split(self):
|
||||
# User-reported bug: when the host empty has scale=-1 on an axis,
|
||||
# the visible +X side of the cube sits on world +X (negative-scale
|
||||
# flips the local +X vertex onto world -X but the local -X vertex
|
||||
# onto world +X — same set of points). The OLD layout used the
|
||||
# signed matrix for positions while rotation-only for normals,
|
||||
# which placed the "+X face" gizmo on world -X. After the
|
||||
# ``_abs_scale_matrix`` fix the gizmo for the +X face must sit
|
||||
# at world +X for an outward-X-facing view to register it as
|
||||
# front-facing.
|
||||
quads = [_FakeQuad() for _ in range(6)]
|
||||
outlines = [_FakeOutline() for _ in range(6)]
|
||||
# View toward +X: the +X face is at world +X for a standard box.
|
||||
view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(Vector((-1.0, 0.0, 0.0)))
|
||||
rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4))
|
||||
# Negative X scale (mirroring the cube along world X).
|
||||
mw = Matrix.Diagonal((-1.0, 1.0, 1.0, 1.0))
|
||||
|
||||
face_quad.apply_face_quad_layout(
|
||||
quad_gizmos=quads,
|
||||
outline_gizmos=outlines,
|
||||
bmin=Vector((-1.0, -1.0, -1.0)),
|
||||
bmax=Vector((1.0, 1.0, 1.0)),
|
||||
matrix_world=mw,
|
||||
cage_rotation=Matrix.Identity(4),
|
||||
region=_FakeRegion(),
|
||||
rv3d=rv3d,
|
||||
locked=False,
|
||||
)
|
||||
|
||||
# Route 1 = (axis=0, is_max=True) = the +X face. Must be solid
|
||||
# (front-facing) for a +X-facing view, regardless of sign-of-scale.
|
||||
assert quads[1]._last_geometry_state == "solid"
|
||||
|
||||
def test_view_parallel_front_face_remains_interactive(self):
|
||||
# Looking dead-on at +Z (view_dir = -Z): the +Z face sits
|
||||
# antiparallel to the view direction, so it's still the
|
||||
# front-facing face. It must render solid (clickable for both
|
||||
# the resize drag and the CTRL+click align-view dispatch),
|
||||
# never hidden — the older "lockout" treatment removed
|
||||
# CTRL+click access on the very face users most want to click.
|
||||
states = _run_layout(view_dir=Vector((0.0, 0.0, -1.0)))
|
||||
|
||||
assert states[5] == "solid" # +Z face (front-facing) stays interactive.
|
||||
# -Z face has no adjacent front-facing neighbours in this view,
|
||||
# so its halo strip degenerates to empty — but that's the
|
||||
# back-face path, not a deliberate lockout.
|
||||
assert states[4] == "empty"
|
||||
@@ -0,0 +1,202 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the ``include_linked_ifc`` toggle contract.
|
||||
|
||||
The toggle extends the cap pipeline to also bisect meshes living inside
|
||||
Project ▸ Links collection-instance empties — without it those meshes
|
||||
are clipped by Blender's native viewport clip but never get
|
||||
cross-section caps drawn at the cut.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
def _make_synthetic_linked_collection(
|
||||
inner_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
instance_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
) -> tuple[bpy.types.Object, bpy.types.Object, bpy.types.Collection]:
|
||||
"""Build a synthetic link: a collection with one mesh + an instance empty.
|
||||
|
||||
Mirrors the structural shape of a real loaded link without driving
|
||||
the multi-process .ifc.cache.blend pipeline. Returns
|
||||
``(instance_empty, inner_mesh, collection)`` so tests can assert
|
||||
against the exact objects they created.
|
||||
"""
|
||||
collection = bpy.data.collections.new("LinkedIFC")
|
||||
bpy.ops.mesh.primitive_cube_add(size=2.0, location=inner_location)
|
||||
inner = bpy.context.active_object
|
||||
for c in list(inner.users_collection):
|
||||
c.objects.unlink(inner)
|
||||
collection.objects.link(inner)
|
||||
|
||||
empty = bpy.data.objects.new("LinkedIFC.001", None)
|
||||
empty.instance_type = "COLLECTION"
|
||||
empty.instance_collection = collection
|
||||
bpy.context.scene.collection.objects.link(empty)
|
||||
# matrix_world (not .location) so the test reads a fresh value without
|
||||
# needing a depsgraph tick to propagate matrix_local → matrix_world.
|
||||
empty.matrix_world = Matrix.Translation(instance_location)
|
||||
|
||||
return empty, inner, collection
|
||||
|
||||
|
||||
def _register_synthetic_link(empty: bpy.types.Object) -> None:
|
||||
"""Add a Project ▸ Links entry pointing at ``empty``.
|
||||
|
||||
No IFC is set in the bootstrap fixture, so
|
||||
``tool.Project.get_link_empty_handle`` resolves via the link's
|
||||
``empty_handle`` PointerProperty rather than the IfcStore.
|
||||
"""
|
||||
project_props = tool.Project.get_project_props()
|
||||
link = project_props.links.add()
|
||||
link.name = "synthetic"
|
||||
link.is_loaded = True
|
||||
link.empty_handle = empty
|
||||
|
||||
|
||||
class TestDefaultIsOff(NewFile):
|
||||
def test_include_linked_ifc_defaults_to_false(self):
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert scene_props.include_linked_ifc is False
|
||||
|
||||
|
||||
class TestIteratorGating(NewFile):
|
||||
def test_iterator_returns_nothing_when_toggle_off(self):
|
||||
empty, _inner, _col = _make_synthetic_linked_collection()
|
||||
_register_synthetic_link(empty)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.include_linked_ifc = False
|
||||
|
||||
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
|
||||
|
||||
assert yielded == []
|
||||
|
||||
def test_iterator_yields_inner_mesh_when_toggle_on(self):
|
||||
empty, inner, _col = _make_synthetic_linked_collection()
|
||||
_register_synthetic_link(empty)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.include_linked_ifc = True
|
||||
|
||||
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
|
||||
|
||||
assert len(yielded) == 1
|
||||
instance, mesh_obj, _world_matrix = yielded[0]
|
||||
assert instance is empty
|
||||
assert mesh_obj is inner
|
||||
|
||||
def test_iterator_composes_instance_and_inner_matrix(self):
|
||||
# The inner mesh's matrix_world is library-local (cube at origin
|
||||
# inside the collection). The instance empty is offset by 5m on X.
|
||||
# The effective world matrix must combine the two so the cap lands
|
||||
# in the active scene, not at the inner mesh's library origin.
|
||||
empty, inner, _col = _make_synthetic_linked_collection(
|
||||
inner_location=(0.0, 0.0, 0.0),
|
||||
instance_location=(5.0, 0.0, 0.0),
|
||||
)
|
||||
_register_synthetic_link(empty)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.include_linked_ifc = True
|
||||
|
||||
_instance, _mesh_obj, world_matrix = next(iter(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)))
|
||||
|
||||
expected = empty.matrix_world @ inner.matrix_world
|
||||
assert (world_matrix.translation - expected.translation).length < 1e-6
|
||||
# And the composition picks up the empty's offset.
|
||||
assert world_matrix.translation.x == pytest.approx(5.0)
|
||||
|
||||
def test_iterator_skips_links_with_no_instance_collection(self):
|
||||
# A link whose empty_handle was created but never linked to a
|
||||
# collection (e.g. half-initialised link) must not yield anything.
|
||||
empty = bpy.data.objects.new("LinkedIFC.broken", None)
|
||||
empty.instance_type = "COLLECTION"
|
||||
bpy.context.scene.collection.objects.link(empty)
|
||||
_register_synthetic_link(empty)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.include_linked_ifc = True
|
||||
|
||||
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
|
||||
|
||||
assert yielded == []
|
||||
|
||||
def test_iterator_skips_unloaded_links(self):
|
||||
empty, _inner, _col = _make_synthetic_linked_collection()
|
||||
project_props = tool.Project.get_project_props()
|
||||
link = project_props.links.add()
|
||||
link.name = "unloaded"
|
||||
link.is_loaded = False
|
||||
link.empty_handle = empty
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.include_linked_ifc = True
|
||||
|
||||
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
|
||||
|
||||
assert yielded == []
|
||||
|
||||
|
||||
class TestUpdateCallbackInvalidatesCache(NewFile):
|
||||
def test_toggling_include_linked_ifc_clears_cap_cache(self):
|
||||
# Seed the cache with a sentinel so we can detect invalidation.
|
||||
tool.ClipBox._cap_cache["sentinel"] = (object(), None)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
|
||||
scene_props.include_linked_ifc = True
|
||||
|
||||
assert "sentinel" not in tool.ClipBox._cap_cache
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
|
||||
class TestRebuildCachesLinkedMesh(NewFile):
|
||||
def test_rebuild_adds_link_prefixed_entry_when_toggle_on(self):
|
||||
# The default clip box spawns a 20m cube around the cursor, so a
|
||||
# 2m cube at the origin sits fully inside both the box and the
|
||||
# instance's translation — guaranteeing the AABB-vs-planes check
|
||||
# passes and a (cache_key, batch) entry lands in _cap_cache.
|
||||
empty, _inner, _col = _make_synthetic_linked_collection()
|
||||
_register_synthetic_link(empty)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.include_linked_ifc = True
|
||||
|
||||
tool.ClipBox.rebuild_caps_now()
|
||||
|
||||
link_keys = [name for name in tool.ClipBox._cap_cache if name.startswith("link:")]
|
||||
assert link_keys, f"expected a link: cache entry, got {list(tool.ClipBox._cap_cache)}"
|
||||
|
||||
def test_rebuild_drops_link_entry_when_toggle_off(self):
|
||||
empty, _inner, _col = _make_synthetic_linked_collection()
|
||||
_register_synthetic_link(empty)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.include_linked_ifc = True
|
||||
tool.ClipBox.rebuild_caps_now()
|
||||
assert any(name.startswith("link:") for name in tool.ClipBox._cap_cache)
|
||||
|
||||
scene_props.include_linked_ifc = False
|
||||
tool.ClipBox.rebuild_caps_now()
|
||||
|
||||
assert not any(name.startswith("link:") for name in tool.ClipBox._cap_cache)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat guards for the source-based clip-box wiring.
|
||||
|
||||
Adding a new source kind requires matching entries across four sites — the
|
||||
label dict, the dispatch table, a callback in ``data.py``, and the menu entry.
|
||||
Missing one path silently degrades the dialog to "No options" with no error.
|
||||
These tests pin the four-way integrity.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.clip_box import data, operator, ui
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
def test_every_label_has_a_dispatch_entry():
|
||||
missing = set(operator.SOURCE_KIND_LABELS) - set(operator._SOURCE_ID_DISPATCH)
|
||||
assert not missing, f"Kinds missing from dispatch: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_every_dispatch_value_is_callable():
|
||||
for kind, fn in operator._SOURCE_ID_DISPATCH.items():
|
||||
assert callable(fn), f"Dispatch entry for {kind} is not callable"
|
||||
|
||||
|
||||
def test_every_dispatch_target_lives_in_data_module():
|
||||
# Each callback must be a real attribute of the data module; protects
|
||||
# against typos in the dispatch table that would otherwise only surface
|
||||
# at the first dialog open.
|
||||
for kind, fn in operator._SOURCE_ID_DISPATCH.items():
|
||||
assert (
|
||||
getattr(data, fn.__name__, None) is fn
|
||||
), f"Dispatch target for {kind} ({fn.__name__}) is not exported from data.py"
|
||||
|
||||
|
||||
def test_every_menu_entry_is_a_known_kind():
|
||||
for kind, label, icon in ui._SOURCE_MENU_ENTRIES:
|
||||
assert kind in operator.SOURCE_KIND_LABELS, f"Menu kind {kind!r} (label={label!r}) is not in SOURCE_KIND_LABELS"
|
||||
|
||||
|
||||
def test_every_label_has_a_menu_entry():
|
||||
menu_kinds = {kind for kind, _label, _icon in ui._SOURCE_MENU_ENTRIES}
|
||||
missing = set(operator.SOURCE_KIND_LABELS) - menu_kinds
|
||||
assert not missing, f"Kinds missing from menu: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_status_values_match_between_tool_and_data():
|
||||
# The status picker labels in data.STATUS_LABELS and the tool-layer
|
||||
# validation list must agree — the dispatcher rejects any status value
|
||||
# missing from the latter.
|
||||
from bonsai.tool.clip_box import SOURCE_STATUS_VALUES
|
||||
|
||||
data_values = tuple(value for value, _label in data.STATUS_LABELS)
|
||||
assert data_values == SOURCE_STATUS_VALUES
|
||||
@@ -24,7 +24,11 @@ from types import SimpleNamespace
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
BaseParametricGizmoGroup,
|
||||
BaseSchematicGizmoGroup,
|
||||
DimensionGizmoConfig,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.drawing
|
||||
|
||||
@@ -52,3 +56,19 @@ def test_text_formatter_receives_props_and_value():
|
||||
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
|
||||
props = SimpleNamespace(label="L")
|
||||
assert config.text_formatter(props, 3.14) == "L=3.14"
|
||||
|
||||
|
||||
def test_parametric_base_enables_dimension_snap_by_default():
|
||||
"""In-place parametric gizmos align to real-world geometry, so dragging
|
||||
must respect the global snap toggle (Ctrl-flip during drag) — same
|
||||
contract every door / window / wall / stair / roof / mep dimension
|
||||
has shipped with."""
|
||||
assert BaseParametricGizmoGroup.snap_enabled_on_dimensions is True
|
||||
|
||||
|
||||
def test_schematic_base_disables_dimension_snap():
|
||||
"""Schematic dimensions float in viewport space; snapping the dragged
|
||||
tip to scene vertices would produce spurious value jumps as the
|
||||
mouse crosses unrelated geometry. The opt-out lives on the base so
|
||||
every schematic subclass inherits it without per-class wiring."""
|
||||
assert BaseSchematicGizmoGroup.snap_enabled_on_dimensions is False
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pin the template-method contract for the connected-network-path decorator
|
||||
base. The base class defines three abstract hooks (`_is_seed_element`,
|
||||
`_walk`, `_build_geometry`) and an `__init_subclass__` that rejects any
|
||||
subclass which leaves a hook un-overridden. Without this guard, a forgotten
|
||||
override would only surface as `NotImplementedError` on the first redraw
|
||||
that hit the missing hook — long after the class declaration."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
_GOOD_HOOKS = {
|
||||
"_is_seed_element": lambda self, element: False,
|
||||
"_walk": lambda self, start_element: [],
|
||||
"_build_geometry": lambda self, connected: ([], [], []),
|
||||
}
|
||||
|
||||
|
||||
def _build_subclass(name, omit=()):
|
||||
from bonsai.bim.module.model.decorator import _ConnectedNetworkPathDecorator
|
||||
|
||||
namespace = {name: fn for name, fn in _GOOD_HOOKS.items() if name not in omit}
|
||||
return type(name, (_ConnectedNetworkPathDecorator,), namespace)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing_hook", sorted(_GOOD_HOOKS))
|
||||
def test_subclass_missing_any_single_hook_raises(missing_hook):
|
||||
with pytest.raises(TypeError, match="must override abstract hook"):
|
||||
_build_subclass(f"DecoratorMissing_{missing_hook}", omit=(missing_hook,))
|
||||
|
||||
|
||||
def test_subclass_missing_all_hooks_raises_naming_each():
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
_build_subclass("DecoratorMissingEverything", omit=tuple(_GOOD_HOOKS))
|
||||
message = str(excinfo.value)
|
||||
for hook in _GOOD_HOOKS:
|
||||
assert hook in message, f"missing-hook error must name {hook!r}"
|
||||
|
||||
|
||||
def test_fully_overridden_subclass_is_accepted():
|
||||
cls = _build_subclass("DecoratorWithAllHooks")
|
||||
assert cls.__name__ == "DecoratorWithAllHooks"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache invalidation — the load-bearing crash guard.
|
||||
#
|
||||
# Without geom-generation gating, the walk cache holds entity_instance
|
||||
# references that outlive their backing IFC entities after an
|
||||
# ifcopenshell.api mutation. The next _build_geometry pass calls .is_a
|
||||
# on a freed SWIG handle and segfaults Blender. The gate must fire
|
||||
# whenever tool.Parametric.get_geom_generation bumps — which is on
|
||||
# every tool.Ifc.Operator commit (via refresh_post_commit), covering
|
||||
# every disconnect path.
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
def _seed_cache(decorator, *, start_guid, ifc_file, geom_gen, walk_ids):
|
||||
decorator._cached_start_guid = start_guid
|
||||
decorator._cached_ifc_file = ifc_file
|
||||
decorator._cached_geom_gen = geom_gen
|
||||
decorator._cached_walk_ids = list(walk_ids)
|
||||
|
||||
|
||||
def test_walk_cache_reuses_when_seed_file_and_geom_gen_unchanged():
|
||||
"""Cache hit: same seed, same ifc_file, same geom_gen → reuse the
|
||||
stored walk. Steady-state path while the IFC is idle."""
|
||||
cls = _build_subclass("DecoratorCacheReuse")
|
||||
dec = cls()
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101, 102])
|
||||
|
||||
current_geom_gen = 5
|
||||
start_guid = "GUID"
|
||||
hit = (
|
||||
start_guid == dec._cached_start_guid
|
||||
and ifc_file is dec._cached_ifc_file
|
||||
and current_geom_gen == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert hit, "Cache must hit when seed, file, and geom_gen are unchanged"
|
||||
|
||||
|
||||
def test_walk_cache_invalidates_on_geom_generation_bump():
|
||||
"""Cache must miss when geom_gen bumps so entities removed by an
|
||||
``ifcopenshell.api`` mutation never survive in the cached walk
|
||||
list into the next draw pass."""
|
||||
cls = _build_subclass("DecoratorCacheGenInvalidates")
|
||||
dec = cls()
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
|
||||
|
||||
current_geom_gen = 6 # IFC mutation has bumped the counter
|
||||
start_guid = "GUID"
|
||||
hit = (
|
||||
start_guid == dec._cached_start_guid
|
||||
and ifc_file is dec._cached_ifc_file
|
||||
and current_geom_gen == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert not hit, "Cache must miss when geom_gen bumps so the walk re-runs against live entities"
|
||||
|
||||
|
||||
def test_walk_cache_invalidates_on_seed_change():
|
||||
"""Selecting a different network seed forces a re-walk even if
|
||||
geom_gen is unchanged."""
|
||||
cls = _build_subclass("DecoratorCacheSeedChange")
|
||||
dec = cls()
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="OLD-GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
|
||||
|
||||
hit = (
|
||||
"NEW-GUID" == dec._cached_start_guid
|
||||
and ifc_file is dec._cached_ifc_file
|
||||
and 5 == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert not hit
|
||||
|
||||
|
||||
def test_walk_cache_invalidates_on_ifc_file_swap():
|
||||
"""Loading a different IFC file must invalidate even if the new
|
||||
seed happens to share the GUID (different IfcOpenShell file
|
||||
objects → different identity)."""
|
||||
cls = _build_subclass("DecoratorCacheFileSwap")
|
||||
dec = cls()
|
||||
|
||||
old_file = SimpleNamespace()
|
||||
new_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=old_file, geom_gen=5, walk_ids=[101])
|
||||
|
||||
hit = (
|
||||
"GUID" == dec._cached_start_guid
|
||||
and new_file is dec._cached_ifc_file
|
||||
and 5 == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert not hit
|
||||
|
||||
|
||||
def test_walk_cache_stores_ids_not_entity_references():
|
||||
"""Structural safety: the cache stores STEP integer ids, not raw
|
||||
``entity_instance`` references — re-resolved via ``ifc_file.by_id``
|
||||
on each cache hit. Eliminates the dangling-SWIG-handle class entirely:
|
||||
even if geom_gen mistakenly fails to bump, a deleted entity's id won't
|
||||
resolve, the cache-hit branch returns ``None``, and the next draw
|
||||
re-walks against live entities."""
|
||||
cls = _build_subclass("DecoratorCacheStoresIds")
|
||||
dec = cls()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=SimpleNamespace(), geom_gen=5, walk_ids=[42])
|
||||
assert dec._cached_walk_ids == [42]
|
||||
assert all(isinstance(eid, int) for eid in dec._cached_walk_ids)
|
||||
|
||||
|
||||
def test_geom_cache_key_includes_geom_generation():
|
||||
"""``TokenCache.get_or_compute`` keys that include geom_gen flush
|
||||
the cached world-space geometry on IFC mutations the depsgraph
|
||||
token doesn't observe — without that key component, a re-walk
|
||||
would feed a fresh list to the lambda while the cache still
|
||||
returned the prior result."""
|
||||
import bonsai.bim.decorator_cache as decorator_cache
|
||||
from bonsai.bim.module.model.decorator import MEPSystemPathDecorator
|
||||
|
||||
decorator_cache.reset_for_test()
|
||||
dec = MEPSystemPathDecorator()
|
||||
|
||||
builds: list[int] = []
|
||||
|
||||
def _build():
|
||||
builds.append(1)
|
||||
return ([], [], [])
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
|
||||
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
|
||||
assert len(builds) == 1, "Same key (same gen) should reuse the cached value"
|
||||
|
||||
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 2), _build)
|
||||
assert len(builds) == 2, "Bumping geom_gen in the key must invalidate the cached value"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-geometry classifier contract.
|
||||
#
|
||||
# Pins the free/connection split that drives the dot colors. The classifier
|
||||
# is plain Python (no bpy / no ifcopenshell), so it runs unconditionally —
|
||||
# the autouse Blender skip in conftest still applies but doesn't bite here.
|
||||
|
||||
_EPS = 1e-5 # well under CONNECTION_EPS_SQ's sqrt (1e-4)
|
||||
|
||||
|
||||
def _cls():
|
||||
from bonsai.bim.module.model.decorator import _ConnectedNetworkPathDecorator
|
||||
|
||||
return _ConnectedNetworkPathDecorator
|
||||
|
||||
|
||||
def test_classifier_empty_input_returns_two_empty_lists():
|
||||
free, conn = _cls()._partition_points_by_coincidence([])
|
||||
assert free == []
|
||||
assert conn == []
|
||||
|
||||
|
||||
def test_classifier_single_point_is_free():
|
||||
p = (1.0, 2.0, 3.0)
|
||||
free, conn = _cls()._partition_points_by_coincidence([p])
|
||||
assert free == [p]
|
||||
assert conn == []
|
||||
|
||||
|
||||
def test_classifier_coincident_pair_dedupes_to_one_connection():
|
||||
p = (1.0, 2.0, 3.0)
|
||||
near = (1.0 + _EPS, 2.0, 3.0)
|
||||
free, conn = _cls()._partition_points_by_coincidence([p, near])
|
||||
assert free == []
|
||||
assert len(conn) == 1
|
||||
|
||||
|
||||
def test_classifier_far_points_stay_free():
|
||||
p1 = (0.0, 0.0, 0.0)
|
||||
p2 = (10.0, 0.0, 0.0)
|
||||
free, conn = _cls()._partition_points_by_coincidence([p1, p2])
|
||||
assert sorted(free) == sorted([p1, p2])
|
||||
assert conn == []
|
||||
|
||||
|
||||
def test_classifier_t_junction_point_on_segment_interior_is_connection():
|
||||
a1, a2 = (0.0, 0.0, 0.0), (5.0, 0.0, 0.0) # wall A endpoints (own segment)
|
||||
b1, b2 = (2.5, -2.0, 0.0), (2.5, 0.0, 0.0) # wall B: T-meets A's midpoint
|
||||
points = [a1, a2, b1, b2]
|
||||
lines = [(a1, a2), (b1, b2)]
|
||||
free, conn = _cls()._partition_points_by_coincidence(points, lines)
|
||||
assert b2 in conn, "T-junction interior touch must be flagged as a connection"
|
||||
assert a1 in free and a2 in free, "wall A free endpoints must stay free"
|
||||
assert b1 in free, "wall B's far endpoint must stay free"
|
||||
|
||||
|
||||
def test_classifier_endpoint_of_own_segment_is_not_a_t_junction():
|
||||
"""A free endpoint sits exactly on its own segment's tip; the interior
|
||||
check must exclude segment endpoints, not just the line interior."""
|
||||
a1, a2 = (0.0, 0.0, 0.0), (5.0, 0.0, 0.0)
|
||||
free, conn = _cls()._partition_points_by_coincidence([a1, a2], [(a1, a2)])
|
||||
assert conn == [], "own-segment endpoints must not self-classify as connection"
|
||||
assert sorted(free) == sorted([a1, a2])
|
||||
|
||||
|
||||
def test_classifier_zero_length_segment_does_not_match():
|
||||
"""A segment whose two endpoints coincide has no interior; the interior
|
||||
check must skip it rather than divide by a near-zero seg_len_sq."""
|
||||
a = (0.0, 0.0, 0.0)
|
||||
p_far = (1.0, 1.0, 1.0)
|
||||
free, conn = _cls()._partition_points_by_coincidence([p_far], [(a, a)])
|
||||
assert free == [p_far]
|
||||
assert conn == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wall topology classifier — IFC-rel-driven endpoint classification.
|
||||
#
|
||||
# Pins the rule "an endpoint is a connection iff an IfcRelConnectsPathElements
|
||||
# rel says so", independent of geometric coincidence. Replaces the geometric
|
||||
# classifier on the wall path because authoring tolerance routinely exceeds
|
||||
# the 0.1 mm epsilon, leaving T-junction dots mis-coloured.
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
def _stub_wall(wid, connected_to=(), connected_from=()):
|
||||
e = Mock()
|
||||
e.id.return_value = wid
|
||||
e.is_a = lambda kind: kind == "IfcWall"
|
||||
e.ConnectedTo = list(connected_to)
|
||||
e.ConnectedFrom = list(connected_from)
|
||||
return e
|
||||
|
||||
|
||||
def _stub_rel(relating, related, relating_type, related_type):
|
||||
r = Mock()
|
||||
r.is_a = lambda kind: kind == "IfcRelConnectsPathElements"
|
||||
r.RelatingElement = relating
|
||||
r.RelatedElement = related
|
||||
r.RelatingConnectionType = relating_type
|
||||
r.RelatedConnectionType = related_type
|
||||
return r
|
||||
|
||||
|
||||
def _wall_cls():
|
||||
from bonsai.bim.module.model.decorator import WallSystemPathDecorator
|
||||
|
||||
return WallSystemPathDecorator
|
||||
|
||||
|
||||
def test_wall_topology_single_wall_no_rels_both_endpoints_free():
|
||||
a = _stub_wall(1)
|
||||
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
|
||||
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
|
||||
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
|
||||
assert conn == []
|
||||
|
||||
|
||||
def test_wall_topology_l_corner_atend_to_atstart_flags_both_endpoints():
|
||||
"""Two walls meeting at a corner: A's ATEND joins B's ATSTART. Each wall's
|
||||
join-side endpoint flips to connection; the far endpoints stay free."""
|
||||
a = _stub_wall(1)
|
||||
b = _stub_wall(2)
|
||||
rel = _stub_rel(relating=a, related=b, relating_type="ATEND", related_type="ATSTART")
|
||||
a.ConnectedTo = [rel]
|
||||
b.ConnectedFrom = [rel]
|
||||
refs = {
|
||||
1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)),
|
||||
2: ((5.0, 0.0, 0.0), (5.0, 5.0, 0.0)),
|
||||
}
|
||||
free, conn = _wall_cls()._classify_endpoints_from_rels([a, b], refs)
|
||||
assert (5.0, 0.0, 0.0) in conn, "A's ATEND endpoint at the corner must be connection"
|
||||
assert (5.0, 0.0, 0.0) in conn, "B's ATSTART endpoint at the corner must be connection"
|
||||
assert (0.0, 0.0, 0.0) in free, "A's far end must stay free"
|
||||
assert (5.0, 5.0, 0.0) in free, "B's far end must stay free"
|
||||
|
||||
|
||||
def test_wall_topology_t_junction_atpath_emits_canonical_join_dot():
|
||||
"""B's ATEND meets A's interior (ATPATH). A's two endpoints stay free,
|
||||
B's ATSTART stays free, B's ATEND is connection, and an extra connection
|
||||
dot is emitted at the T-meets point computed by
|
||||
``tool.Wall.path_connection_location_world``."""
|
||||
a = _stub_wall(1)
|
||||
b = _stub_wall(2)
|
||||
rel = _stub_rel(relating=b, related=a, relating_type="ATEND", related_type="ATPATH")
|
||||
a.ConnectedFrom = [rel]
|
||||
b.ConnectedTo = [rel]
|
||||
refs = {
|
||||
1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)),
|
||||
2: ((2.5, -2.0, 0.0), (2.5, 0.0, 0.0)),
|
||||
}
|
||||
t_meets = (2.5, 0.0, 0.0)
|
||||
with patch("bonsai.tool.Wall.path_connection_location_world", return_value=t_meets):
|
||||
free, conn = _wall_cls()._classify_endpoints_from_rels([a, b], refs)
|
||||
assert t_meets in conn, "T-meets canonical join must be a connection dot"
|
||||
assert (2.5, 0.0, 0.0) in conn, "B's ATEND at the junction must also be a connection"
|
||||
assert (0.0, 0.0, 0.0) in free and (5.0, 0.0, 0.0) in free, "A's endpoints stay free"
|
||||
assert (2.5, -2.0, 0.0) in free, "B's ATSTART (far end) stays free"
|
||||
|
||||
|
||||
def test_wall_topology_rel_to_wall_outside_walked_set_is_ignored():
|
||||
"""A rel pointing at a wall whose id is not in ``refs`` must not classify
|
||||
the participating endpoint as connection — only intra-set joins count."""
|
||||
a = _stub_wall(1)
|
||||
outside = _stub_wall(99)
|
||||
rel = _stub_rel(relating=a, related=outside, relating_type="ATEND", related_type="ATSTART")
|
||||
a.ConnectedTo = [rel]
|
||||
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
|
||||
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
|
||||
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
|
||||
assert conn == []
|
||||
|
||||
|
||||
def test_wall_topology_non_path_rels_are_ignored():
|
||||
"""``ConnectedTo`` can carry ``IfcRelConnectsElements`` (slab clip rels);
|
||||
only ``IfcRelConnectsPathElements`` contribute to wall endpoint topology."""
|
||||
a = _stub_wall(1)
|
||||
non_path_rel = Mock()
|
||||
non_path_rel.is_a = lambda kind: kind == "IfcRelConnectsElements"
|
||||
a.ConnectedTo = [non_path_rel]
|
||||
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
|
||||
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
|
||||
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
|
||||
assert conn == []
|
||||
|
||||
|
||||
def test_wall_topology_dedupe_collapses_overlapping_connection_dots():
|
||||
"""Two connection dots at the same world point (within eps) collapse to
|
||||
one — used by ``_build_geometry`` to keep ATPATH joins from stacking on
|
||||
neighbour-wall endpoints."""
|
||||
p = (1.0, 2.0, 3.0)
|
||||
near = (1.0 + 1e-6, 2.0, 3.0)
|
||||
far = (10.0, 0.0, 0.0)
|
||||
result = _wall_cls()._dedupe_close_points([p, near, far], 1e-4 * 1e-4)
|
||||
assert len(result) == 2
|
||||
assert p in result and far in result
|
||||
@@ -0,0 +1,596 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour tests for the unified ``bim.disconnect_elements`` operator and
|
||||
``tool.Connection.find_rels`` registry.
|
||||
|
||||
Pin the dispatch contract: rels are found in either orientation; the kind
|
||||
label drives cleanup (``path`` recreates both walls + resyncs drafts;
|
||||
``element-top`` runs ``regenerate_wall_to_underside``); missing endpoints
|
||||
report ERROR rather than crashing."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: int = 0):
|
||||
rel = Mock()
|
||||
rel.is_a = lambda c: c == klass
|
||||
rel.RelatingElement = relating
|
||||
rel.RelatedElement = related
|
||||
rel.Description = description
|
||||
rel.id = lambda: rel_id
|
||||
return rel
|
||||
|
||||
|
||||
def _elem(*, connected_to=(), connected_from=()):
|
||||
e = Mock()
|
||||
# Default is_a to False so the MEP-pair-fitting branch of find_rels
|
||||
# (which calls ``elem.is_a("IfcFlowSegment")``) early-outs on the
|
||||
# generic _elem stubs used by the wall-side dispatch tests. Test
|
||||
# cases that want is_a("IfcWall")-True explicitly override e.is_a.
|
||||
e.is_a = lambda _c: False
|
||||
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_rels — MEP pair-fitting detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mep(elem_id, *, klasses=("IfcFlowSegment",), predefined_type=None, ports=()):
|
||||
"""Stand-in IFC element with port mocks and ``is_a`` short-circuits."""
|
||||
e = Mock()
|
||||
e.id = lambda: elem_id
|
||||
e.is_a = lambda c: c in klasses
|
||||
e.PredefinedType = predefined_type
|
||||
# Empty path / element rels so the find_rels prologue iterates cleanly
|
||||
# before reaching the MEP port-walk branch.
|
||||
e.ConnectedTo = []
|
||||
e.ConnectedFrom = []
|
||||
e._ports = list(ports)
|
||||
return e
|
||||
|
||||
|
||||
def _port(port_id, owner, connected_to=None):
|
||||
p = Mock()
|
||||
p.id = lambda: port_id
|
||||
p._owner = owner
|
||||
p._connected_to = connected_to
|
||||
return p
|
||||
|
||||
|
||||
def _patch_port_walk():
|
||||
"""Patch the port helpers ``tool.System.find_bridging_fitting`` consumes
|
||||
so the mep-pair-fitting detection in ``find_rels`` can be exercised
|
||||
without a real IFC fixture. Three patches: ``get_ports`` and
|
||||
``get_connected_port`` are ``tool.System`` classmethods that delegate
|
||||
to ``ifcopenshell.util.system``; ``get_port_element`` is called
|
||||
directly on ``ifcopenshell.util.system`` inside ``neighbours_at_ports``."""
|
||||
return (
|
||||
patch("bonsai.tool.system.System.get_ports", side_effect=lambda e: e._ports),
|
||||
patch(
|
||||
"bonsai.tool.system.System.get_connected_port",
|
||||
side_effect=lambda p: p._connected_to,
|
||||
),
|
||||
patch(
|
||||
"bonsai.tool.system.ifcopenshell.util.system.get_port_element",
|
||||
side_effect=lambda p: p._owner,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_find_rels_detects_segment_segment_bridging_fitting():
|
||||
"""Two flow segments joined by a single bridging fitting must surface
|
||||
as ``(fitting, 'mep-pair-fitting')`` — the fitting whose deletion
|
||||
effects the disconnect."""
|
||||
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
|
||||
seg_a = _mep(1)
|
||||
seg_b = _mep(2)
|
||||
|
||||
a_port = _port(101, seg_a)
|
||||
b_port = _port(102, seg_b)
|
||||
f_port_a = _port(201, fitting, connected_to=a_port)
|
||||
f_port_b = _port(202, fitting, connected_to=b_port)
|
||||
a_port._connected_to = f_port_a
|
||||
b_port._connected_to = f_port_b
|
||||
|
||||
seg_a._ports = [a_port]
|
||||
seg_b._ports = [b_port]
|
||||
fitting._ports = [f_port_a, f_port_b]
|
||||
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
rels = tool.Connection.find_rels(seg_a, seg_b)
|
||||
|
||||
assert rels == [(fitting, "mep-pair-fitting")]
|
||||
|
||||
|
||||
def test_find_rels_detects_segment_fitting_direct():
|
||||
"""A segment + its directly-connected fitting also surface as the
|
||||
same kind, with the fitting itself as the deletion target."""
|
||||
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
|
||||
seg = _mep(1)
|
||||
seg_port = _port(101, seg)
|
||||
f_port = _port(201, fitting, connected_to=seg_port)
|
||||
seg_port._connected_to = f_port
|
||||
seg._ports = [seg_port]
|
||||
fitting._ports = [f_port]
|
||||
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
rels = tool.Connection.find_rels(seg, fitting)
|
||||
|
||||
assert rels == [(fitting, "mep-pair-fitting")]
|
||||
|
||||
|
||||
def test_find_rels_skips_obstruction_fitting():
|
||||
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
|
||||
they must not surface as a disconnect target."""
|
||||
obstruction = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="OBSTRUCTION")
|
||||
seg_a = _mep(1)
|
||||
seg_b = _mep(2)
|
||||
a_port = _port(101, seg_a)
|
||||
b_port = _port(102, seg_b)
|
||||
o_port_a = _port(201, obstruction, connected_to=a_port)
|
||||
o_port_b = _port(202, obstruction, connected_to=b_port)
|
||||
a_port._connected_to = o_port_a
|
||||
b_port._connected_to = o_port_b
|
||||
seg_a._ports = [a_port]
|
||||
seg_b._ports = [b_port]
|
||||
obstruction._ports = [o_port_a, o_port_b]
|
||||
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
assert tool.Connection.find_rels(seg_a, seg_b) == []
|
||||
|
||||
|
||||
def test_find_rels_returns_empty_for_two_unrelated_mep_segments():
|
||||
"""No bridging fitting, no detection."""
|
||||
seg_a = _mep(1)
|
||||
seg_b = _mep(2)
|
||||
seg_a._ports = []
|
||||
seg_b._ports = []
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
assert tool.Connection.find_rels(seg_a, seg_b) == []
|
||||
|
||||
|
||||
def test_find_rels_skips_non_mep_pair():
|
||||
"""Walls don't have ports — find_rels must early-out before walking
|
||||
them as if they were MEP."""
|
||||
wall_a = Mock()
|
||||
wall_a.is_a = lambda c: c == "IfcWall"
|
||||
wall_a.ConnectedTo = []
|
||||
wall_a.ConnectedFrom = []
|
||||
wall_b = Mock()
|
||||
wall_b.is_a = lambda c: c == "IfcWall"
|
||||
wall_b.ConnectedTo = []
|
||||
wall_b.ConnectedFrom = []
|
||||
|
||||
assert tool.Connection.find_rels(wall_a, wall_b) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_subject, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
|
||||
kw = call.kwargs
|
||||
assert kw["subject"] is expected_subject
|
||||
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["subject"] is rel and slab_first["subject"] 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()
|
||||
@@ -128,14 +128,14 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
|
||||
call_counts = {"port_connection_state": 0, "find_bridging_fitting": 0, "compute_mep_join_location": 0}
|
||||
|
||||
def counting_port_state(elem, at_start):
|
||||
call_counts["port_connection_state"] += 1
|
||||
return "FREE"
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
call_counts["find_fitting_between_segments"] += 1
|
||||
call_counts["find_bridging_fitting"] += 1
|
||||
return None
|
||||
|
||||
def counting_join_location():
|
||||
@@ -151,7 +151,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
),
|
||||
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
|
||||
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
|
||||
patch("bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting),
|
||||
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
|
||||
@@ -164,7 +164,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
|
||||
|
||||
# Second frame must reuse the cached values — no second IFC walk.
|
||||
assert call_counts["port_connection_state"] == first["port_connection_state"]
|
||||
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
|
||||
assert call_counts["find_bridging_fitting"] == first["find_bridging_fitting"]
|
||||
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
@@ -218,9 +218,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
|
||||
assert (
|
||||
fitting_call_count["n"] > first_fitting
|
||||
), "find_fitting_between_segments must recompute after generation advance"
|
||||
assert fitting_call_count["n"] > first_fitting, "find_bridging_fitting must recompute after generation advance"
|
||||
|
||||
|
||||
def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
@@ -252,7 +250,7 @@ def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
@@ -265,4 +263,4 @@ def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
selection_state["selected"] = [active, other_b]
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
|
||||
assert fitting_call_count["n"] > first, "find_bridging_fitting must recompute after selection change"
|
||||
|
||||
@@ -160,41 +160,102 @@ def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
|
||||
)
|
||||
|
||||
|
||||
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
|
||||
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
|
||||
``position`` pinned. Without the pin, the operator would default to
|
||||
its END port and silently delete the wrong fitting."""
|
||||
def test_unjoin_icons_bind_unified_disconnect_operator():
|
||||
"""Every unjoin icon (pair, start, end) routes to the unified
|
||||
``bim.disconnect_elements`` operator and the group holds an
|
||||
``op_props`` slot for each so the per-frame GUID writes have a
|
||||
target."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
|
||||
op_props = gz.target_set_operator.return_value
|
||||
assert op_props.position == expected_position or op_props.position in ("START", "END")
|
||||
|
||||
|
||||
def test_unjoin_icons_get_warning_color_highlight():
|
||||
"""Destructive icons surface in the addon's warning red on hover so
|
||||
they read as a deliberate target. ``color_highlight`` is overridden
|
||||
after ``super().setup()`` wires the default highlight."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
warning_color = (1.0, 0.1, 0.1)
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
assert isinstance(inst.unjoin_op_props, dict)
|
||||
for name in GizmoMEPActions.UNJOIN_CONFIGS:
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
|
||||
gz.target_set_operator.assert_any_call("bim.disconnect_elements")
|
||||
assert name in inst.unjoin_op_props, f"missing op_props slot for {name!r}"
|
||||
|
||||
|
||||
def test_bind_unjoin_pair_writes_both_guids():
|
||||
"""``_bind_unjoin_pair`` is the per-frame hand-off from gizmo
|
||||
position-gizmos to the unified disconnect operator: both segment
|
||||
GlobalIds get written onto the pre-wired op_props so a click
|
||||
dispatches with the right pair."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
pair_op_props = inst.unjoin_op_props["unjoin_pair"]
|
||||
|
||||
seg_a = Mock(GlobalId="GUID-A")
|
||||
seg_b = Mock(GlobalId="GUID-B")
|
||||
|
||||
assert GizmoMEPActions._bind_unjoin_pair(inst, [seg_a, seg_b]) is True
|
||||
assert pair_op_props.element_a_guid == "GUID-A"
|
||||
assert pair_op_props.element_b_guid == "GUID-B"
|
||||
|
||||
|
||||
def test_bind_unjoin_pair_rejects_incomplete_pair():
|
||||
"""Defensive: a selection mid-change can hand the gizmo a one-element
|
||||
or None-containing pair. The bind must refuse rather than write a
|
||||
half-resolved op_props that would later CANCEL with a confusing
|
||||
error message."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A")]) is False
|
||||
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A"), None]) is False
|
||||
|
||||
|
||||
def test_bind_unjoin_at_port_resolves_fitting_and_writes_guids():
|
||||
"""The per-port unjoin gizmo resolves the partner fitting at the
|
||||
named port and writes (segment_guid, fitting_guid) onto the
|
||||
pre-wired op_props so the unified disconnect operator gets both
|
||||
endpoints."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
port_op_props = inst.unjoin_op_props["unjoin_end"]
|
||||
|
||||
segment_obj = Mock()
|
||||
segment = Mock(GlobalId="SEG-GUID")
|
||||
fitting = Mock(GlobalId="FIT-GUID")
|
||||
fitting.is_a = lambda c: c == "IfcFlowFitting"
|
||||
fitting.PredefinedType = "BEND"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
|
||||
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=fitting
|
||||
):
|
||||
ok = GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", segment_obj, False)
|
||||
|
||||
assert ok is True
|
||||
assert port_op_props.element_a_guid == "SEG-GUID"
|
||||
assert port_op_props.element_b_guid == "FIT-GUID"
|
||||
|
||||
|
||||
def test_bind_unjoin_at_port_refuses_obstruction_partner():
|
||||
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
|
||||
routing them through the unified disconnect would just delete the
|
||||
fitting and leave a visible gap. Mirror the find_rels exclusion
|
||||
here so the icon hides when the partner is an obstruction."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
segment = Mock(GlobalId="SEG-GUID")
|
||||
obstruction = Mock(GlobalId="OBS-GUID")
|
||||
obstruction.is_a = lambda c: c == "IfcFlowFitting"
|
||||
obstruction.PredefinedType = "OBSTRUCTION"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
|
||||
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=obstruction
|
||||
):
|
||||
assert GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", Mock(), False) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -202,6 +263,66 @@ def test_unjoin_icons_get_warning_color_highlight():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_accepts_tessellated_bend_with_bbim_pset():
|
||||
"""A bend whose body has been tessellated as the upstream geometry-kernel
|
||||
workaround still has its parametric definition on the type's
|
||||
``BBIM_Fitting`` pset — the re-edit operator reads from there, so the
|
||||
pen icon must surface on it. ``has_parametric_body`` would return False
|
||||
for the tessellated body; the pset gate is what makes the icon
|
||||
reachable."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
bend_obj = Mock()
|
||||
bend_elem = Mock()
|
||||
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
bend_type = Mock()
|
||||
bend_type.PredefinedType = "BEND"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
|
||||
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
|
||||
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
|
||||
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset",
|
||||
return_value={"radius": 0.2, "start_length": 0.1, "end_length": 0.1},
|
||||
):
|
||||
assert _active_is_bend_fitting(bend_obj) is True
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_rejects_bend_type_without_bbim_pset():
|
||||
"""A fitting that looks like a bend (IfcFlowFitting + type.PredefinedType
|
||||
== BEND) but lacks a ``BBIM_Fitting`` pset on the type can't be re-edited
|
||||
— the re-edit operator reads parameters from the pset. Reject so the pen
|
||||
icon hides rather than dispatching an operator that would CANCEL."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
bend_obj = Mock()
|
||||
bend_elem = Mock()
|
||||
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
bend_type = Mock()
|
||||
bend_type.PredefinedType = "BEND"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
|
||||
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
|
||||
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
|
||||
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", return_value=None
|
||||
):
|
||||
assert _active_is_bend_fitting(bend_obj) is False
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_rejects_non_bend():
|
||||
"""Non-bend objects (segments, fittings with PredefinedType != BEND)
|
||||
fail the first gate regardless of pset state."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
bend_obj = Mock()
|
||||
bend_elem = Mock()
|
||||
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
|
||||
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=False
|
||||
):
|
||||
assert _active_is_bend_fitting(bend_obj) is False
|
||||
|
||||
|
||||
def test_active_is_flow_segment_handles_unbound_object():
|
||||
"""A Blender object with no IFC binding must not raise from a
|
||||
visibility predicate. The lambda runs on every selection event."""
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""End-to-end integration tests for the unified MEP disconnect path.
|
||||
|
||||
Builds a real IFC scene (two pipe segments joined via ports to a bridging
|
||||
fitting) and exercises the full chain:
|
||||
tool.Connection.find_rels(seg_a, seg_b)
|
||||
→ returns (fitting, "mep-pair-fitting")
|
||||
→ bonsai.core.connection.disconnect_rel(subject=fitting, kind=...)
|
||||
→ tool.Geometry.delete_ifc_object(fitting_obj)
|
||||
→ cascade-on-delete removes the IfcRelConnectsPorts via remove_port
|
||||
|
||||
The mock-based dispatch tests in :py:mod:`test_disconnect_elements` pin each
|
||||
piece in isolation. This module pins that they compose — the surface the
|
||||
gizmo click hits in production."""
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.api.system
|
||||
import pytest
|
||||
|
||||
import bonsai.core.connection
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
class TestMEPPairDisconnectEndToEnd(NewFile):
|
||||
def _make_segment(self, name: str):
|
||||
"""Create one IfcPipeSegment occurrence with ports at both ends.
|
||||
Returns (blender_object, ifc_element)."""
|
||||
bpy.ops.mesh.primitive_cube_add(size=1)
|
||||
obj = bpy.data.objects["Cube"]
|
||||
obj.name = name
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcPipeSegment", predefined_type="RIGIDSEGMENT", userdefined_type="")
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
tool.System.add_ports(obj)
|
||||
return obj, element
|
||||
|
||||
def _make_bend_fitting(self, name: str):
|
||||
"""Create one IfcPipeFitting (PredefinedType=BEND) occurrence with two
|
||||
ports. Manual setup — bpy.ops.bim.assign_class doesn't add ports."""
|
||||
bpy.ops.mesh.primitive_cube_add(size=0.3)
|
||||
obj = bpy.data.objects["Cube"]
|
||||
obj.name = name
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcPipeFitting", predefined_type="BEND", userdefined_type="")
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
tool.System.add_ports(obj)
|
||||
return obj, element
|
||||
|
||||
def _setup_joined_pair(self):
|
||||
bpy.ops.bim.create_project()
|
||||
seg_a_obj, seg_a = self._make_segment("SegA")
|
||||
seg_b_obj, seg_b = self._make_segment("SegB")
|
||||
bend_obj, bend = self._make_bend_fitting("Bend")
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
seg_a_ports = tool.System.get_ports(seg_a)
|
||||
seg_b_ports = tool.System.get_ports(seg_b)
|
||||
bend_ports = tool.System.get_ports(bend)
|
||||
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_a_ports[0], port2=bend_ports[0])
|
||||
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_b_ports[0], port2=bend_ports[1])
|
||||
|
||||
return seg_a, seg_b, bend, bend_obj
|
||||
|
||||
def test_find_rels_returns_mep_pair_fitting_subject(self):
|
||||
seg_a, seg_b, bend, _ = self._setup_joined_pair()
|
||||
rels = tool.Connection.find_rels(seg_a, seg_b)
|
||||
assert rels == [(bend, "mep-pair-fitting")]
|
||||
|
||||
def test_disconnect_rel_removes_the_bridging_fitting(self):
|
||||
"""The end-to-end contract: dispatch removes the fitting from the
|
||||
IFC file, the Blender object is deleted, and a follow-up find_rels
|
||||
on the same pair returns empty — there's nothing left to disconnect."""
|
||||
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
|
||||
bend_id = bend.id()
|
||||
bend_obj_name = bend_obj.name
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
subject=bend,
|
||||
kind="mep-pair-fitting",
|
||||
elem=seg_a,
|
||||
partner=seg_b,
|
||||
)
|
||||
|
||||
# The fitting is gone from the IFC file.
|
||||
with pytest.raises(RuntimeError):
|
||||
ifc_file.by_id(bend_id)
|
||||
# The pair is no longer joined.
|
||||
assert tool.Connection.find_rels(seg_a, seg_b) == []
|
||||
# The Blender object was removed by delete_ifc_object.
|
||||
assert bend_obj_name not in bpy.data.objects
|
||||
|
||||
def test_disconnect_rel_skips_when_subject_is_elem_being_deleted(self):
|
||||
"""Cascade-side guard: if the fitting is itself the element being
|
||||
deleted (subject is elem), skip — the deletion is already in flight
|
||||
and re-deleting would crash."""
|
||||
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
|
||||
bend_id = bend.id()
|
||||
bend_obj_name = bend_obj.name
|
||||
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
subject=bend,
|
||||
kind="mep-pair-fitting",
|
||||
elem=bend,
|
||||
partner=seg_a,
|
||||
skip_elem_recreate=True,
|
||||
)
|
||||
|
||||
# Fitting still present — the dispatch correctly skipped.
|
||||
assert tool.Ifc.get().by_id(bend_id).id() == bend_id
|
||||
assert bend_obj_name in bpy.data.objects
|
||||
@@ -61,76 +61,6 @@ def _make_op(_cls, **fields):
|
||||
return op
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinAtPort
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"port_state, fitting_predefined_type, expected_result, expects_delete",
|
||||
[
|
||||
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
|
||||
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
|
||||
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
|
||||
],
|
||||
)
|
||||
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
|
||||
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
|
||||
by ``(port_state, fitting type)``.
|
||||
|
||||
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
|
||||
the bridging fitting is deleted via the standard delete entry point.
|
||||
- ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go
|
||||
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
|
||||
extends to absorb the freed length; using delete here would leave
|
||||
a visible gap.
|
||||
- ``FREE``: nothing to do — no bridging fitting exists. The operator
|
||||
reports a user-facing error and CANCELS rather than no-op silently."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
|
||||
mep, "get_connected_element_at_segment_port", return_value=fitting
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == expected_result
|
||||
if expects_delete:
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
else:
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_at_port_cancels_when_active_is_not_segment():
|
||||
"""The operator only operates on flow segments; non-segment active
|
||||
objects must fail loud rather than mutate something unexpected."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = fitting
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPRemoveTerminalFitting
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -210,105 +140,6 @@ def test_remove_terminal_cancels_on_non_terminal_port():
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinPair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unjoin_pair_deletes_bridging_fitting():
|
||||
"""Happy path: two selected segments share a single non-OBSTRUCTION
|
||||
bridging fitting → delete it."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
fitting = _fitting(predefined_type="JUNCTION")
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
|
||||
|
||||
def test_unjoin_pair_refuses_obstruction_bridging():
|
||||
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
|
||||
through the dedicated REMOVE path; this operator surfaces the
|
||||
redirect rather than silently doing the wrong thing."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
obstruction = _fitting(predefined_type="OBSTRUCTION")
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
|
||||
"""The pair is selected but no single fitting bridges them — the
|
||||
user is told instead of getting a silent no-op."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
|
||||
"""The poll filters the gizmo, but a programmatic invocation could
|
||||
still hand the operator an invalid selection. The execute path
|
||||
independently verifies both inputs are IfcFlowSegment."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
|
||||
):
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SelectMEPPathMembers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the active-is-survivor merge convention.
|
||||
|
||||
``bim.merge_wall`` must consume the non-active selection into the active
|
||||
one — matching Blender's ``OBJECT_OT_join`` / ``MESH_OT_merge`` "at
|
||||
last" convention. Users following Ctrl+J muscle-memory click the
|
||||
surviving wall last; the operator must align with that expectation."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _run_perform(active, other):
|
||||
"""Invoke ``MergeWall._perform`` as an unbound function with the
|
||||
two wall stubs in the selection, patching the heavy IFC / Blender
|
||||
side effects. Returns the ``(merger_arg_1, merger_arg_2)`` actually
|
||||
passed to ``DumbWallJoiner.merge``."""
|
||||
from bonsai.bim.module.model.wall import MergeWall
|
||||
|
||||
context = SimpleNamespace(active_object=active)
|
||||
captured_call = {}
|
||||
|
||||
def _capture_merge(self, a, b):
|
||||
captured_call["wall1"] = a
|
||||
captured_call["wall2"] = b
|
||||
|
||||
fake_self = SimpleNamespace()
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.get_selected_mesh_objects", return_value=[active, other]),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.__init__", return_value=None),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.merge", new=_capture_merge),
|
||||
patch("bonsai.bim.module.model.wall._maybe_resync_wall_props_from_ifc"),
|
||||
patch("bonsai.bim.module.model.wall._regenerate_walls") as regen_walls,
|
||||
):
|
||||
result = MergeWall._perform(fake_self, context)
|
||||
|
||||
return captured_call, regen_walls, result
|
||||
|
||||
|
||||
def test_active_wall_is_passed_as_survivor_to_merge():
|
||||
"""The first argument to ``DumbWallJoiner.merge`` is the survivor;
|
||||
the active object must occupy that slot so the wall the user clicked
|
||||
last absorbs the other."""
|
||||
active = SimpleNamespace(name="active")
|
||||
other = SimpleNamespace(name="other")
|
||||
|
||||
captured, _regen, _ = _run_perform(active, other)
|
||||
|
||||
assert captured["wall1"] is active
|
||||
assert captured["wall2"] is other
|
||||
|
||||
|
||||
def test_post_merge_resync_targets_active_not_consumed():
|
||||
"""After the merge ``_regenerate_walls`` rebuilds the survivor's
|
||||
body. Targeting the consumed wall would crash on a freed ``bpy_struct``;
|
||||
the survivor (active) is the only valid target."""
|
||||
active = SimpleNamespace(name="active")
|
||||
other = SimpleNamespace(name="other")
|
||||
|
||||
_, regen_walls, _ = _run_perform(active, other)
|
||||
|
||||
regen_walls.assert_called_once_with([active])
|
||||
@@ -0,0 +1,300 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle
|
||||
behaviour railing inherits from ``PathPreservingEditMixin``.
|
||||
|
||||
The parent short-circuit (skip the IFC commit / viewport rebuild when the
|
||||
draft is identical to the stored pset) lives in
|
||||
``PathPreservingEditMixin``; the tests below verify railing's subclass
|
||||
honours that contract by inheritance, then pin the railing-specific
|
||||
viewport-restore dispatch:
|
||||
|
||||
- Finish / Cancel no-op short-circuit: inherited from the parent — verified
|
||||
here because railing was the original consumer that motivated the
|
||||
optimisation.
|
||||
- ``_RailingEditMixin._restore_viewport_after_cancel`` dispatch: WALL_MOUNTED_HANDRAIL
|
||||
reloads the high-poly Body representation via ``switch_representation``;
|
||||
FRAMELESS_PANEL rebuilds the bmesh preview via
|
||||
``update_railing_modifier_bmesh``. This is the per-type branch that used
|
||||
to live in ``_cancel_one`` and now lives in the viewport-restore hook the
|
||||
parent's ``_cancel_one`` calls.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from test.bim.conftest import _FakePropsBase
|
||||
from test.bim.conftest import make_lifecycle_obj as _make_obj
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeRailingProps(_FakePropsBase):
|
||||
"""Stand-in for ``BIMRailingProperties`` — adds ``railing_type`` on top of
|
||||
the shared parametric-edit contract. Starts in ``is_editing=True`` because
|
||||
the railing-specific overrides under test only fire on Finish / Cancel,
|
||||
not on Enable."""
|
||||
|
||||
def __init__(self, railing_type: str = "WALL_MOUNTED_HANDRAIL", general: dict | None = None):
|
||||
super().__init__(general=general if general is not None else {"railing_type": railing_type, "height": 1.0})
|
||||
self.railing_type = railing_type
|
||||
self.is_editing = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_railing():
|
||||
"""Patch the railing module's external references for unit testing.
|
||||
|
||||
``_RailingEditMixin`` and the parent lifecycle reach for
|
||||
``tool.Model.get_modeling_bbim_pset_data``, ``tool.Ifc.get_entity``,
|
||||
``ifcopenshell.util.representation.get_representation``,
|
||||
``bonsai.core.geometry.switch_representation``, and the module-level
|
||||
``update_railing_modifier_bmesh`` — each looked up through the railing
|
||||
module's own bindings, so we patch them there.
|
||||
|
||||
``parametric_lifecycle.tool`` is patched separately so the parent's
|
||||
``_resolve`` and ``_cancel_one`` can read ``tool.Model.get_modeling_bbim_pset_data``
|
||||
without falling through to the real Blender bindings.
|
||||
|
||||
Uses ``mock.patch.object`` with a direct module reference rather than
|
||||
the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")``
|
||||
needs ``pkgutil.resolve_name`` to traverse ``bonsai → bim → module → …``,
|
||||
which fails at the ``bonsai.bim`` step until that subpackage has been
|
||||
imported elsewhere. The direct-object form sidesteps the resolution.
|
||||
|
||||
Returns a dict for tests to seed return values and assert call sites.
|
||||
"""
|
||||
from bonsai.bim import parametric_lifecycle
|
||||
from bonsai.bim.module.model import railing
|
||||
|
||||
with (
|
||||
mock.patch.object(railing, "tool") as mock_tool,
|
||||
mock.patch.object(railing, "ifcopenshell") as mock_ifc,
|
||||
mock.patch.object(railing, "bonsai") as mock_bonsai,
|
||||
mock.patch.object(railing, "update_railing_modifier_bmesh") as mock_update_bmesh,
|
||||
mock.patch.object(parametric_lifecycle, "tool") as mock_pl_tool,
|
||||
):
|
||||
# _resolve will be overridden on the test subclass below so the
|
||||
# parametric_lifecycle.tool patch isn't needed for that path, but the
|
||||
# parent's _cancel_one / _finish_one still call
|
||||
# tool.Model.get_modeling_bbim_pset_data and would otherwise miss.
|
||||
mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
|
||||
yield {
|
||||
"tool": mock_tool,
|
||||
"ifcopenshell": mock_ifc,
|
||||
"bonsai": mock_bonsai,
|
||||
"update_bmesh": mock_update_bmesh,
|
||||
"pl_tool": mock_pl_tool,
|
||||
}
|
||||
|
||||
|
||||
def _railing_test_subclass(props):
|
||||
"""Build a ``_RailingEditMixin`` subclass that bypasses ``_resolve``.
|
||||
|
||||
The base ``_resolve`` reads ``tool.Ifc.get_entity`` from
|
||||
``parametric_lifecycle.tool`` (a separate import from the railing
|
||||
module's ``tool``). Overriding it here keeps the test patches local
|
||||
to the railing module and the hook closures local to the test."""
|
||||
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||
|
||||
test_element = mock.Mock(name="ifc_element")
|
||||
|
||||
class _TestRailingMixin(_RailingEditMixin):
|
||||
pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset")
|
||||
ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data")
|
||||
bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel")
|
||||
|
||||
@classmethod
|
||||
def _resolve(cls, obj):
|
||||
return test_element, props
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data):
|
||||
cls.pset_updates(element, data)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj, context):
|
||||
cls.ifc_data_updates(obj, context)
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj, context):
|
||||
cls.bmesh_updates(obj, context)
|
||||
|
||||
# The base _post_load_data JSON-serialises path_data; bypass that
|
||||
# here so the round-trip stays a plain dict and tests can compare
|
||||
# by reference / equality without re-parsing.
|
||||
@classmethod
|
||||
def _post_load_data(cls, data):
|
||||
return dict(data)
|
||||
|
||||
return _TestRailingMixin, test_element
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _RailingEditMixin._finish_one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing):
|
||||
"""Enable → Finish without any property edit must NOT write to IFC.
|
||||
|
||||
Without this, every "open Edit, click Validate immediately" cycle
|
||||
would create a fresh ``IfcShapeRepresentation``, pollute the file's
|
||||
representation list, and burn an undo entry — the user-visible
|
||||
regression that motivated the short-circuit.
|
||||
|
||||
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
|
||||
the coverage as the original consumer of the contract.
|
||||
"""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
props = _FakeRailingProps(general=dict(stored))
|
||||
obj = _make_obj(props)
|
||||
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||
}
|
||||
|
||||
cls, _element = _railing_test_subclass(props)
|
||||
cls._finish_one(obj, mock.Mock(name="context"))
|
||||
|
||||
assert props.is_editing is False, "is_editing must still flip even on no-op"
|
||||
cls.pset_updates.assert_not_called()
|
||||
cls.ifc_data_updates.assert_not_called()
|
||||
|
||||
|
||||
def test_finish_one_writes_when_draft_differs(patched_railing):
|
||||
"""The complement of the short-circuit: a real property change must
|
||||
flow through to ``_update_pset`` + ``_update_modifier_ifc_data``."""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
# Draft height differs: simulating a user edit.
|
||||
props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5})
|
||||
obj = _make_obj(props)
|
||||
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||
}
|
||||
|
||||
cls, element = _railing_test_subclass(props)
|
||||
cls._finish_one(obj, mock.Mock(name="context"))
|
||||
|
||||
assert props.is_editing is False
|
||||
cls.pset_updates.assert_called_once()
|
||||
# The pset must receive the DRAFT data, not the stored data — that's the
|
||||
# whole point of Finish committing the user's edits.
|
||||
written = cls.pset_updates.call_args[0][1]
|
||||
assert written["height"] == 1.5
|
||||
cls.ifc_data_updates.assert_called_once_with(obj, mock.ANY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _RailingEditMixin._cancel_one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
|
||||
"""Cancel-without-changes is asymmetrically expensive without this guard:
|
||||
``switch_representation`` re-tessellates the IfcSweptDiskSolid and is
|
||||
visibly slow on a long handrail. When nothing changed, the mesh on
|
||||
screen is still the committed IFC representation (the preview only
|
||||
builds on a property change) — skip the reload entirely.
|
||||
|
||||
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
|
||||
the coverage as the original consumer of the contract.
|
||||
"""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
props = _FakeRailingProps(general=dict(stored))
|
||||
obj = _make_obj(props)
|
||||
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||
}
|
||||
|
||||
cls, _element = _railing_test_subclass(props)
|
||||
cls._cancel_one(obj, mock.Mock(name="context"))
|
||||
|
||||
assert props.is_editing is False
|
||||
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
|
||||
patched_railing["update_bmesh"].assert_not_called()
|
||||
cls.bmesh_updates.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _RailingEditMixin._restore_viewport_after_cancel — per-type viewport-restore dispatch
|
||||
#
|
||||
# The parent's _cancel_one calls cls._restore_viewport_after_cancel whenever
|
||||
# the draft differs from the stored pset. Railing's override branches on
|
||||
# railing_type so WALL_MOUNTED_HANDRAIL reloads the high-poly Body
|
||||
# representation rather than rebuilding the low-poly cylinder-segment preview.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_viewport_wall_mounted_handrail_switches_representation(patched_railing):
|
||||
"""WALL_MOUNTED_HANDRAIL restore must call ``switch_representation`` with
|
||||
the Body representation — the preview is viewport-only (low-poly cylinder)
|
||||
and would persist visibly without the reload."""
|
||||
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||
|
||||
props = _FakeRailingProps(railing_type="WALL_MOUNTED_HANDRAIL")
|
||||
obj = _make_obj(props)
|
||||
patched_railing["tool"].Model.get_railing_props.return_value = props
|
||||
body_repr = mock.Mock(name="body_representation")
|
||||
patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
|
||||
|
||||
_RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context"))
|
||||
|
||||
patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once()
|
||||
kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs
|
||||
assert kwargs["obj"] is obj
|
||||
assert kwargs["representation"] is body_repr
|
||||
# Must NOT fall through to the FRAMELESS bmesh-rebuild path.
|
||||
patched_railing["update_bmesh"].assert_not_called()
|
||||
|
||||
|
||||
def test_restore_viewport_frameless_panel_calls_module_bmesh_rebuild(patched_railing):
|
||||
"""FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
|
||||
swept-disk solid to reload. The restore must delegate to the module-level
|
||||
``update_railing_modifier_bmesh`` rebuilder rather than swap representations."""
|
||||
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||
|
||||
props = _FakeRailingProps(railing_type="FRAMELESS_PANEL")
|
||||
obj = _make_obj(props)
|
||||
patched_railing["tool"].Model.get_railing_props.return_value = props
|
||||
ctx = mock.Mock(name="context")
|
||||
|
||||
_RailingEditMixin._restore_viewport_after_cancel(obj, ctx)
|
||||
|
||||
patched_railing["update_bmesh"].assert_called_once_with(ctx)
|
||||
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_railing_path_anchor: tests removed.
|
||||
#
|
||||
# The schematic-redesign branch replaced ``GizmoRailingEdition`` with
|
||||
# ``GizmoRailingSchematic``, which anchors via the schematic frame rather
|
||||
# than the polyline's first vertex. ``_get_railing_path_anchor`` was the
|
||||
# helper for the old anchor strategy and has been deleted along with the
|
||||
# old gizmo group. If schematic-mode gains a similar path-derived helper,
|
||||
# new tests should land here.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,270 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
BaseSchematicGizmoGroup,
|
||||
DimensionGizmoConfig,
|
||||
)
|
||||
from bonsai.bim.module.model.railing import GizmoRailingSchematic
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
"""Skip the file when ``bpy`` is mocked or absent.
|
||||
|
||||
Without this guard, mis-routed test runs (e.g. ``pytest test/bim/...``
|
||||
invoked outside Blender) crash at module-collection time on the chain of
|
||||
``bonsai.tool`` imports below, instead of producing a clean ``skipped``.
|
||||
"""
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
# ── Class shape ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_railing_schematic_inherits_base():
|
||||
"""GizmoRailingSchematic plugs into the schematic framework, not the
|
||||
in-place dimension framework. If a future refactor breaks this lineage
|
||||
the schematic-specific machinery (sliders, draw handler) silently goes
|
||||
dormant."""
|
||||
assert issubclass(GizmoRailingSchematic, BaseSchematicGizmoGroup)
|
||||
|
||||
|
||||
def test_railing_schematic_bl_idname_preserved():
|
||||
"""``OBJECT_GGT_bim_railing_edition`` is the user-facing identifier and
|
||||
is referenced by keymaps and persistence. Preserve it across the class
|
||||
rename — see the migration note in the class docstring."""
|
||||
assert GizmoRailingSchematic.bl_idname == "OBJECT_GGT_bim_railing_edition"
|
||||
|
||||
|
||||
def test_railing_schematic_props_getter_pairing():
|
||||
"""``gizmo_pref_name = "railing"`` and ``props_getter = tool.Model.get_railing_props``
|
||||
are the pairing test_parametric_registry depends on. If either drifts,
|
||||
the addon-preferences gizmo toggle silently stops controlling this group."""
|
||||
assert GizmoRailingSchematic.gizmo_pref_name == "railing"
|
||||
assert GizmoRailingSchematic.props_getter == tool.Model.get_railing_props
|
||||
|
||||
|
||||
def test_railing_schematic_disables_in_place_dimension_props():
|
||||
"""The schematic owns the value-input surface — no in-place dimensions on the actual geometry."""
|
||||
assert GizmoRailingSchematic.dimension_gizmo_props == []
|
||||
|
||||
|
||||
# ── Dimension configuration ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_railing_schematic_has_six_dimensions():
|
||||
"""One dimension per parametric property — three for each railing_type."""
|
||||
assert len(GizmoRailingSchematic.schematic_dimension_props) == 6
|
||||
|
||||
|
||||
def test_railing_schematic_dimension_attr_names_complete():
|
||||
"""The six bound attributes match the parametric properties that
|
||||
``update_railing_modifier_bmesh`` reads when regenerating the live preview."""
|
||||
attr_names = {c.attr_name for c in GizmoRailingSchematic.schematic_dimension_props}
|
||||
assert attr_names == {
|
||||
"height",
|
||||
"thickness",
|
||||
"spacing",
|
||||
"railing_diameter",
|
||||
"clear_width",
|
||||
"support_spacing",
|
||||
}
|
||||
|
||||
|
||||
def test_railing_schematic_dimensions_are_dimension_configs():
|
||||
"""The dimension-line aesthetic depends on ``DimensionGizmoConfig`` (with
|
||||
arrows + label), not the abstract slider widget."""
|
||||
for config in GizmoRailingSchematic.schematic_dimension_props:
|
||||
assert isinstance(config, DimensionGizmoConfig)
|
||||
|
||||
|
||||
def test_railing_schematic_dimensions_have_text_formatters():
|
||||
"""Each dimension must format the label from the actual property value,
|
||||
not from the visually-scaled value the gizmo's getter returns. Without a
|
||||
formatter the label would show the schematic-scaled length, which is
|
||||
meaningless to the user."""
|
||||
for config in GizmoRailingSchematic.schematic_dimension_props:
|
||||
assert config.text_formatter is not None, f"{config.attr_name} missing text_formatter"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attr_name,railing_type,expected",
|
||||
[
|
||||
("height", "FRAMELESS_PANEL", True),
|
||||
("height", "WALL_MOUNTED_HANDRAIL", False),
|
||||
("thickness", "FRAMELESS_PANEL", True),
|
||||
("spacing", "FRAMELESS_PANEL", True),
|
||||
("railing_diameter", "WALL_MOUNTED_HANDRAIL", True),
|
||||
("railing_diameter", "FRAMELESS_PANEL", False),
|
||||
("clear_width", "WALL_MOUNTED_HANDRAIL", True),
|
||||
],
|
||||
)
|
||||
def test_railing_schematic_dimension_visibility_gated_by_railing_type(attr_name, railing_type, expected):
|
||||
"""The two railing types are mutually exclusive — height/thickness/spacing
|
||||
belong to FRAMELESS_PANEL; railing_diameter/clear_width/support_spacing
|
||||
belong to WALL_MOUNTED_HANDRAIL. The visibility lambdas enforce that."""
|
||||
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == attr_name)
|
||||
props = SimpleNamespace(railing_type=railing_type, use_manual_supports=False)
|
||||
assert config.visibility_condition(props) is expected
|
||||
|
||||
|
||||
def test_railing_schematic_support_spacing_hidden_for_manual_supports():
|
||||
"""``support_spacing`` only drives auto-positioned supports — when the
|
||||
user has switched to manual supports the dimension should disappear."""
|
||||
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == "support_spacing")
|
||||
auto = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=False)
|
||||
manual = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=True)
|
||||
assert config.visibility_condition(auto) is True
|
||||
assert config.visibility_condition(manual) is False
|
||||
|
||||
|
||||
# ── Fixed-length tag rendering ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_schematic_dim_visible_length_is_constant():
|
||||
"""Every schematic dimension tag renders at the same width — the bar is a
|
||||
UI affordance, not a proportional measurement. The constant ratio keeps
|
||||
tiny (5 mm thickness) and huge (5 m height) values equally clickable; the
|
||||
real value lives in the dimension label.
|
||||
|
||||
Regression guard: if value-proportional scaling is reintroduced, this
|
||||
contract breaks silently — small dimensions start collapsing into stacked
|
||||
arrows again.
|
||||
"""
|
||||
cls = GizmoRailingSchematic
|
||||
ratio = cls.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO
|
||||
assert ratio > 0
|
||||
assert ratio <= 1.0 # bar must fit within the schematic box
|
||||
|
||||
|
||||
def test_schematic_no_compute_schematic_scale_override():
|
||||
"""The constant-length schematic must not reintroduce scale-based
|
||||
proportional sizing via a ``_compute_schematic_scale`` override."""
|
||||
assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__
|
||||
|
||||
|
||||
# ── Path-edit guard ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_editing_gizmos_override_defined_on_subclass():
|
||||
"""``GizmoRailingSchematic`` must own the override that hides the pen
|
||||
icon during path-edit. The parent's version shows the pen whenever
|
||||
``is_editing`` is False, which includes path-edit; that would let the
|
||||
user open two editing modes at once."""
|
||||
assert "update_editing_gizmos" in GizmoRailingSchematic.__dict__
|
||||
|
||||
|
||||
# ── Schematic mesh building ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_schematic_mesh_frameless_panel_returns_bmesh_with_edges():
|
||||
"""FRAMELESS_PANEL renders as two separated wireframe boxes — 8 corners
|
||||
per box × 2 = 16 verts; 12 edges per box × 2 = 24 edges. The visible
|
||||
gap between the two boxes is the "spacing" semantic made literal.
|
||||
|
||||
The mesh proportions are fixed (independent of property values) so the
|
||||
dimension gizmos can anchor to known feature positions; the property
|
||||
values are shown through dimension labels, not the mesh size."""
|
||||
props = SimpleNamespace(
|
||||
railing_type="FRAMELESS_PANEL",
|
||||
height=1.0,
|
||||
thickness=0.05,
|
||||
spacing=0.5,
|
||||
)
|
||||
bm = GizmoRailingSchematic.build_schematic_mesh(props)
|
||||
try:
|
||||
assert isinstance(bm, bmesh.types.BMesh)
|
||||
assert len(bm.verts) == 16
|
||||
assert len(bm.edges) == 24
|
||||
finally:
|
||||
bm.free()
|
||||
|
||||
|
||||
def test_build_schematic_mesh_wall_mounted_handrail_returns_bmesh_with_edges():
|
||||
"""WALL_MOUNTED_HANDRAIL renders as three visual elements:
|
||||
|
||||
- **Wall outline** — 4 corner verts, 4 edges (rectangle at z=0).
|
||||
- **Hex tube** — 12 verts (6 per ring × 2 ends), 18 edges
|
||||
(6 left ring + 6 right ring + 6 axial).
|
||||
- **L-brackets** at each rail end — 3 verts per bracket (rail centre,
|
||||
corner, wall attach) × 2 brackets = 6 verts; 2 edges per bracket
|
||||
(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()
|
||||
@@ -0,0 +1,107 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the branching contract of ``tool.Model.regenerate_wall``.
|
||||
|
||||
The body rebuild always runs (extrusion + openings); the slab re-clip only
|
||||
runs when an ``IfcRelConnectsElements(TOP)`` rel survives. A wall without
|
||||
either feature still completes without crashing."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def test_regenerate_wall_rebuilds_body_and_reclips_when_connected():
|
||||
"""Wall with a TOP connection: body rebuilt first, then re-clipped."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=True), patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_called_once_with(element, obj)
|
||||
regen.assert_called_once()
|
||||
args, _ = regen.call_args
|
||||
assert args[3] == [obj]
|
||||
|
||||
|
||||
def test_regenerate_wall_skips_reclip_when_no_top_rel():
|
||||
"""Wall without a TOP connection: body rebuilt; re-clip skipped."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=False), patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_called_once_with(element, obj)
|
||||
regen.assert_not_called()
|
||||
|
||||
|
||||
def test_regenerate_wall_noops_when_obj_has_no_ifc_entity():
|
||||
"""Non-IFC objects (e.g. a freshly created Blender mesh before
|
||||
`tool.Ifc.run("root.create_entity")` runs) return None from get_entity;
|
||||
the helper must return without touching the body or any rels."""
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=None), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection") as has_top, patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_not_called()
|
||||
has_top.assert_not_called()
|
||||
regen.assert_not_called()
|
||||
|
||||
|
||||
def test_recreate_wall_noops_when_wall_has_no_layer_set():
|
||||
"""``recreate_wall`` must short-circuit when
|
||||
``regenerate_wall_representation`` returns ``None``. That API only knows
|
||||
how to rebuild ``IfcMaterialLayerSet`` walls; for walls without one it
|
||||
returns ``None``, and feeding ``None`` to ``switch_representation``
|
||||
crashes deep inside ``resolve_representation`` on ``.Items``."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Parametric.is_fillet_corner_wall", return_value=False), patch(
|
||||
"bonsai.tool.model.tool.Ifc.get", return_value=Mock()
|
||||
), patch("bonsai.tool.model.ifcopenshell.api.geometry.regenerate_wall_representation", return_value=None), patch(
|
||||
"bonsai.tool.model.bonsai.core.geometry.switch_representation"
|
||||
) as switch, patch.object(
|
||||
tool.Geometry, "record_object_materials"
|
||||
) as record:
|
||||
tool.Model.recreate_wall(element, obj)
|
||||
|
||||
switch.assert_not_called()
|
||||
record.assert_not_called()
|
||||
@@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule):
|
||||
|
||||
- ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base
|
||||
parametric poll already filters array children.
|
||||
- ``GizmoSlabEdition`` — same as ``GizmoWallEdition`` (inherits
|
||||
``BaseParametricGizmoGroup`` whose poll filters array children).
|
||||
- ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire
|
||||
WHILE its own preview is active; routing it through the topology gate
|
||||
would self-block it.
|
||||
@@ -47,9 +49,11 @@ pytestmark = pytest.mark.model
|
||||
|
||||
# Wall gizmo groups intentionally outside the rule. Add a new entry only
|
||||
# with the in-code reasoning above.
|
||||
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
|
||||
_ALLOWLIST = frozenset({"GizmoSlabEdition", "GizmoWallEdition", "GizmoWallFilletPreview"})
|
||||
|
||||
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
|
||||
_REQUIRED_CALLEES = frozenset(
|
||||
{"_wall_topology_gizmo_poll_gate", "_slab_connection_gizmo_poll_gate", "any_selected_is_array_child"}
|
||||
)
|
||||
|
||||
|
||||
def _wall_module_source():
|
||||
|
||||
@@ -50,12 +50,15 @@ def _make_context(active, selected):
|
||||
return SimpleNamespace(active_object=active, selected_objects=list(selected))
|
||||
|
||||
|
||||
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
|
||||
def _patch_tools(
|
||||
prefs_on, selected, active_element, other_element, active_usage, other_usage, other_is_path_connectable=None
|
||||
):
|
||||
"""Return a stack of patches that simulate one selection / IFC state for poll().
|
||||
|
||||
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
|
||||
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
|
||||
so the test only depends on the predicate ordering in poll()."""
|
||||
selection set, the IFC entity lookup, the usage-type lookup, and the
|
||||
path-connectable-wall predicate are stubbed so the test only depends on
|
||||
the predicate ordering in poll()."""
|
||||
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
|
||||
|
||||
entity_map = {}
|
||||
@@ -67,12 +70,18 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
usage_map[id(active_element)] = active_usage
|
||||
usage_map[id(other_element)] = other_usage
|
||||
|
||||
if other_is_path_connectable is None:
|
||||
other_is_path_connectable = other_usage == "LAYER2"
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def get_usage_type(element):
|
||||
return usage_map.get(id(element))
|
||||
|
||||
def is_path_connectable_wall(element):
|
||||
return element is other_element and other_is_path_connectable
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
return [
|
||||
@@ -80,6 +89,7 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
|
||||
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
|
||||
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
|
||||
patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=is_path_connectable_wall),
|
||||
# The array-child filter is pinned by its own test file; stub it here
|
||||
# so these poll tests stay focused on the count / layer-usage gates
|
||||
# and don't have to scaffold the memoization cache key.
|
||||
@@ -87,7 +97,15 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
]
|
||||
|
||||
|
||||
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
|
||||
def _run_poll(
|
||||
prefs_on,
|
||||
active_is_in_selected,
|
||||
len_override,
|
||||
active_usage,
|
||||
other_usage,
|
||||
active_has_entity=True,
|
||||
other_is_path_connectable=None,
|
||||
):
|
||||
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
|
||||
|
||||
slab_obj = _Obj("slab")
|
||||
@@ -103,7 +121,15 @@ def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other
|
||||
slab_element = object() if active_has_entity else None
|
||||
wall_element = object()
|
||||
|
||||
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
|
||||
patches = _patch_tools(
|
||||
prefs_on,
|
||||
selected,
|
||||
slab_element,
|
||||
wall_element,
|
||||
active_usage,
|
||||
other_usage,
|
||||
other_is_path_connectable=other_is_path_connectable,
|
||||
)
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
@@ -189,6 +215,23 @@ def test_poll_rejects_when_other_is_not_layer2_wall():
|
||||
)
|
||||
|
||||
|
||||
def test_poll_accepts_fillet_corner_wall_partner():
|
||||
# Fillet-corner walls carry no LAYER2 usage by spec but the extend-to-
|
||||
# underside operator handles them just like a parametric LAYER2 wall —
|
||||
# the gizmo must surface for the slab + fillet-corner selection too.
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True,
|
||||
active_is_in_selected=True,
|
||||
len_override=None,
|
||||
active_usage="LAYER3",
|
||||
other_usage=None,
|
||||
other_is_path_connectable=True,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the contract that ``DumbWallJoiner.merge`` re-hosts openings from
|
||||
the discarded wall to the survivor before the cascade delete tears down
|
||||
``element2.HasOpenings`` and any filling that references them.
|
||||
|
||||
``edit_object_placement`` preserves the opening's world position when the
|
||||
two walls have different placements — a ``PlacementRelTo`` swap alone
|
||||
would shift the opening as the relative offset changes."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _opening_rel(opening_id: int, placement_matrix: np.ndarray):
|
||||
"""Build a stub ``IfcRelVoidsElement`` carrying an opening with a known
|
||||
placement. ``RelatingBuildingElement`` is settable so the test can
|
||||
observe the re-host."""
|
||||
opening = Mock(name=f"opening_{opening_id}")
|
||||
opening.id.return_value = opening_id
|
||||
opening.ObjectPlacement = Mock(name=f"opening_placement_{opening_id}")
|
||||
rel = Mock(name=f"voids_rel_{opening_id}")
|
||||
rel.RelatedOpeningElement = opening
|
||||
rel.RelatingBuildingElement = None
|
||||
return rel, opening, placement_matrix
|
||||
|
||||
|
||||
def _merge_inputs(*, has_openings):
|
||||
"""Stage the minimum wall1 + wall2 + element1 + element2 surface that
|
||||
``DumbWallJoiner.merge`` reads. The reference lines and placements are
|
||||
rigged so the collinearity guard passes and execution reaches the
|
||||
opening-migration loop."""
|
||||
wall1 = Mock(name="wall1")
|
||||
wall2 = Mock(name="wall2")
|
||||
element1 = Mock(name="element1")
|
||||
element2 = Mock(name="element2")
|
||||
element1.ObjectPlacement = Mock(name="elem1_placement")
|
||||
element2.ObjectPlacement = Mock(name="elem2_placement")
|
||||
element1.ConnectedTo = []
|
||||
element1.ConnectedFrom = []
|
||||
element2.ConnectedTo = []
|
||||
element2.ConnectedFrom = []
|
||||
element2.HasOpenings = list(has_openings)
|
||||
return wall1, wall2, element1, element2
|
||||
|
||||
|
||||
def _run_merge(wall1, wall2, element1, element2, opening_matrices, captured_edit_calls):
|
||||
"""Invoke ``DumbWallJoiner().merge`` against the staged inputs with
|
||||
every heavy IFC / Blender side effect patched out. ``opening_matrices``
|
||||
maps an opening id to its captured world matrix; ``captured_edit_calls``
|
||||
is appended to whenever ``edit_object_placement`` fires."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
def fake_get_local_placement(placement):
|
||||
for rel in element2.HasOpenings:
|
||||
if rel.RelatedOpeningElement.ObjectPlacement is placement:
|
||||
return opening_matrices[rel.RelatedOpeningElement.id()]
|
||||
return np.eye(4)
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return {wall1: element1, wall2: element2}[obj]
|
||||
|
||||
def fake_edit_object_placement(ifc_file, *, product, matrix, is_si, should_transform_children):
|
||||
captured_edit_calls.append(
|
||||
{
|
||||
"product": product,
|
||||
"matrix": matrix,
|
||||
"is_si": is_si,
|
||||
"should_transform_children": should_transform_children,
|
||||
}
|
||||
)
|
||||
|
||||
p1 = np.array([0.0, 0.0])
|
||||
p2 = np.array([5.0, 0.0])
|
||||
p3 = np.array([5.0, 0.0])
|
||||
p4 = np.array([10.0, 0.0])
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
|
||||
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
|
||||
side_effect=fake_get_local_placement,
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.edit_object_placement",
|
||||
side_effect=fake_edit_object_placement,
|
||||
),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
|
||||
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object") as delete_ifc_object,
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
|
||||
):
|
||||
DumbWallJoiner().merge(wall1, wall2)
|
||||
return delete_ifc_object
|
||||
|
||||
|
||||
def test_merge_rehosts_each_opening_to_survivor():
|
||||
"""Every void rel on the discarded wall is rebound to the survivor so
|
||||
the cascade delete doesn't take them down with element2."""
|
||||
matrix_a = np.eye(4)
|
||||
matrix_a[0, 3] = 1.0
|
||||
matrix_b = np.eye(4)
|
||||
matrix_b[0, 3] = 3.0
|
||||
rel_a, opening_a, _ = _opening_rel(opening_id=101, placement_matrix=matrix_a)
|
||||
rel_b, opening_b, _ = _opening_rel(opening_id=102, placement_matrix=matrix_b)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel_a, rel_b])
|
||||
|
||||
_run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={101: matrix_a, 102: matrix_b},
|
||||
captured_edit_calls=[],
|
||||
)
|
||||
|
||||
assert rel_a.RelatingBuildingElement is element1
|
||||
assert rel_b.RelatingBuildingElement is element1
|
||||
|
||||
|
||||
def test_merge_preserves_opening_world_placement():
|
||||
"""``edit_object_placement`` re-applies the opening's pre-merge world
|
||||
matrix so the void doesn't drift when the two walls have different
|
||||
placements — the regression a ``PlacementRelTo`` swap alone would
|
||||
fail."""
|
||||
matrix = np.eye(4)
|
||||
matrix[:3, 3] = (2.5, 0.0, 0.0)
|
||||
rel, opening, _ = _opening_rel(opening_id=42, placement_matrix=matrix)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
|
||||
captured: list[dict] = []
|
||||
|
||||
_run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={42: matrix},
|
||||
captured_edit_calls=captured,
|
||||
)
|
||||
|
||||
edit_calls_for_opening = [call for call in captured if call["product"] is opening]
|
||||
assert len(edit_calls_for_opening) == 1
|
||||
np.testing.assert_allclose(edit_calls_for_opening[0]["matrix"], matrix, atol=1e-9)
|
||||
assert edit_calls_for_opening[0]["should_transform_children"] is False
|
||||
|
||||
|
||||
def test_merge_rehosts_before_delete():
|
||||
"""Order matters: ``delete_ifc_object`` cascades through
|
||||
``element2.HasOpenings`` and would destroy the void if it ran before
|
||||
the re-host. Assert the survivor was rebound before delete fires."""
|
||||
matrix = np.eye(4)
|
||||
rel, opening, _ = _opening_rel(opening_id=7, placement_matrix=matrix)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
|
||||
|
||||
delete_ifc_object = _run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={7: matrix},
|
||||
captured_edit_calls=[],
|
||||
)
|
||||
|
||||
assert rel.RelatingBuildingElement is element1
|
||||
delete_ifc_object.assert_called_once_with(wall2)
|
||||
|
||||
|
||||
def test_merge_skips_non_path_connection_rels():
|
||||
"""``ConnectedTo`` / ``ConnectedFrom`` carry both
|
||||
``IfcRelConnectsPathElements`` (wall-wall joins) AND
|
||||
``IfcRelConnectsElements`` (slab underside clips). Only the path rels
|
||||
expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
|
||||
accessing those attributes on an element rel raises ``AttributeError``.
|
||||
The migration loop must filter on the rel class so a wall with a slab
|
||||
clip can still be merged."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[])
|
||||
|
||||
path_rel = Mock(name="path_rel")
|
||||
path_rel.is_a = lambda c: c == "IfcRelConnectsPathElements"
|
||||
path_rel.RelatingElement = Mock(name="rel_relating")
|
||||
path_rel.RelatedElement = Mock(name="rel_related")
|
||||
path_rel.RelatingConnectionType = "ATSTART"
|
||||
path_rel.RelatedConnectionType = "ATEND"
|
||||
|
||||
slab_rel = Mock(name="slab_rel")
|
||||
slab_rel.is_a = lambda c: c == "IfcRelConnectsElements"
|
||||
slab_rel.Description = "TOP"
|
||||
# ``RelatedConnectionType`` is what the merge loop reads from
|
||||
# ``ConnectedFrom``; the real ``IfcRelConnectsElements`` schema has
|
||||
# no such attribute, so wire the stub to raise like ifcopenshell does.
|
||||
type(slab_rel).RelatedConnectionType = property(
|
||||
lambda self: (_ for _ in ()).throw(AttributeError("RelatedConnectionType"))
|
||||
)
|
||||
type(slab_rel).RelatingConnectionType = property(
|
||||
lambda self: (_ for _ in ()).throw(AttributeError("RelatingConnectionType"))
|
||||
)
|
||||
element2.ConnectedFrom = [slab_rel, path_rel]
|
||||
|
||||
captured_disconnects = []
|
||||
captured_connects = []
|
||||
|
||||
def fake_disconnect_path(*args, **kwargs):
|
||||
captured_disconnects.append(kwargs)
|
||||
|
||||
def fake_connect_path(*args, **kwargs):
|
||||
captured_connects.append(kwargs)
|
||||
|
||||
p1 = np.array([0.0, 0.0])
|
||||
p2 = np.array([5.0, 0.0])
|
||||
p3 = np.array([5.0, 0.0])
|
||||
p4 = np.array([10.0, 0.0])
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return {wall1: element1, wall2: element2}[obj]
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
|
||||
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
|
||||
return_value=np.eye(4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_path",
|
||||
side_effect=fake_disconnect_path,
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.connect_path",
|
||||
side_effect=fake_connect_path,
|
||||
),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
|
||||
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object"),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
|
||||
):
|
||||
# The bug pre-fix: the slab rel's ``RelatedConnectionType`` access
|
||||
# raised AttributeError and crashed merge. With the filter, this
|
||||
# call must complete cleanly.
|
||||
DumbWallJoiner().merge(wall1, wall2)
|
||||
|
||||
assert len(captured_disconnects) == 1
|
||||
assert len(captured_connects) == 1
|
||||
assert captured_disconnects[0]["connection_type"] == "ATEND"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the contract that the three dimension-mutating wall operators —
|
||||
``bim.change_extrusion_depth``, ``bim.change_extrusion_x_angle``,
|
||||
``bim.change_layer_length`` — re-prime ``BIMWallProperties`` from the
|
||||
post-mutation IFC at the end of ``_execute``.
|
||||
|
||||
Without the resync, ``props.height`` / ``props.length`` / ``props.x_angle``
|
||||
stay at their pre-mutation values; gizmo icons that position from
|
||||
``props.height`` then sit at the old elevation even though the wall mesh
|
||||
shows the new one."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _execute_source(operator_cls):
|
||||
return inspect.getsource(operator_cls._execute)
|
||||
|
||||
|
||||
def test_change_extrusion_depth_resyncs_wall_props():
|
||||
"""Height mutation must re-prime ``BIMWallProperties.height`` so
|
||||
gizmo icons positioned from ``props.height`` track the post-mutation
|
||||
wall top in the same redraw."""
|
||||
from bonsai.bim.module.model.wall import ChangeExtrusionDepth
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth)
|
||||
|
||||
|
||||
def test_change_extrusion_x_angle_resyncs_wall_props():
|
||||
"""Slope mutation must re-prime ``BIMWallProperties.x_angle`` so
|
||||
slope-driven gizmo positions track the new angle."""
|
||||
from bonsai.bim.module.model.wall import ChangeExtrusionXAngle
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle)
|
||||
|
||||
|
||||
def test_change_layer_length_resyncs_wall_props():
|
||||
"""Length mutation must re-prime ``BIMWallProperties.length`` so
|
||||
horizontal gizmo X positions track the new axis extent."""
|
||||
from bonsai.bim.module.model.wall import ChangeLayerLength
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength)
|
||||
@@ -0,0 +1,213 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour tests for the wall-slab connection helpers on tool.Wall.
|
||||
|
||||
Pins the rel-shape contract (IfcRelConnectsElements with Description=="TOP")
|
||||
the underside-extension feature creates, and the icon placement contract the
|
||||
new wall-slab connection gizmo group reads."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _rel(klass: str = "IfcRelConnectsElements", description: str = "TOP", relating=None, related=None):
|
||||
rel = Mock()
|
||||
rel.is_a = lambda c: c == klass
|
||||
rel.Description = description
|
||||
rel.RelatingElement = relating
|
||||
rel.RelatedElement = related
|
||||
return rel
|
||||
|
||||
|
||||
def _wall_with_rels(*rels) -> Mock:
|
||||
wall = Mock()
|
||||
wall.ConnectedFrom = list(rels)
|
||||
return wall
|
||||
|
||||
|
||||
def _slab_with_rels(*rels) -> Mock:
|
||||
slab = Mock()
|
||||
slab.ConnectedTo = list(rels)
|
||||
return slab
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iter_wall_slab_connections — yields (slab, rel) for TOP rels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_yields_top_rels():
|
||||
slab_a = Mock(name="slab_a")
|
||||
slab_b = Mock(name="slab_b")
|
||||
wall = _wall_with_rels(
|
||||
_rel(relating=slab_a),
|
||||
_rel(relating=slab_b),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert result == [(slab_a, wall.ConnectedFrom[0]), (slab_b, wall.ConnectedFrom[1])]
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_skips_non_top_description():
|
||||
"""Only TOP-described rels count; BOTTOM / SIDE / arbitrary strings are
|
||||
skipped so other RelConnectsElements semantics aren't confused with the
|
||||
underside-extension contract."""
|
||||
slab = Mock()
|
||||
wall = _wall_with_rels(
|
||||
_rel(description="BOTTOM", relating=slab),
|
||||
_rel(description="TOP", relating=slab),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][0] is slab
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_skips_non_connectselements_rels():
|
||||
"""Path-connections to other walls show up on ConnectedFrom too — the
|
||||
helper must filter on rel class, not just presence."""
|
||||
slab = Mock()
|
||||
wall = _wall_with_rels(
|
||||
_rel(klass="IfcRelConnectsPathElements", relating=slab),
|
||||
_rel(klass="IfcRelConnectsElements", relating=slab),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_handles_none_relating():
|
||||
"""A malformed rel with RelatingElement=None is skipped rather than
|
||||
raising — defensive against partially-loaded IFC files."""
|
||||
wall = _wall_with_rels(_rel(relating=None))
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_empty_when_no_connectedfrom():
|
||||
wall = Mock()
|
||||
wall.ConnectedFrom = []
|
||||
|
||||
assert list(tool.Wall.iter_wall_slab_connections(wall)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iter_slab_wall_connections — mirror, walks slab.ConnectedTo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iter_slab_wall_connections_yields_top_rels():
|
||||
wall_a = Mock()
|
||||
wall_b = Mock()
|
||||
slab = _slab_with_rels(
|
||||
_rel(related=wall_a),
|
||||
_rel(related=wall_b),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_slab_wall_connections(slab))
|
||||
|
||||
assert [w for w, _ in result] == [wall_a, wall_b]
|
||||
|
||||
|
||||
def test_iter_slab_wall_connections_skips_non_top():
|
||||
wall = Mock()
|
||||
slab = _slab_with_rels(
|
||||
_rel(description="BOTTOM", related=wall),
|
||||
_rel(description="TOP", related=wall),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_slab_wall_connections(slab))
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_wall_slab_rel — locate specific rel between wall + slab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_wall_slab_rel_returns_match():
|
||||
slab_a = Mock(name="slab_a")
|
||||
slab_b = Mock(name="slab_b")
|
||||
rel_a = _rel(relating=slab_a)
|
||||
rel_b = _rel(relating=slab_b)
|
||||
wall = _wall_with_rels(rel_a, rel_b)
|
||||
|
||||
assert tool.Wall.find_wall_slab_rel(wall, slab_b) is rel_b
|
||||
|
||||
|
||||
def test_find_wall_slab_rel_returns_none_when_unconnected():
|
||||
slab_a = Mock(name="slab_a")
|
||||
other_slab = Mock(name="other_slab")
|
||||
wall = _wall_with_rels(_rel(relating=slab_a))
|
||||
|
||||
assert tool.Wall.find_wall_slab_rel(wall, other_slab) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wall_slab_connection_location_world — icon anchor point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wall_slab_connection_location_perches_above_wall_top():
|
||||
"""Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh
|
||||
bbox top in world space plus WALL_SLAB_CONNECTION_Z_CLEARANCE so the
|
||||
icon sits above the extend-vertical / slope gizmo at the wall top."""
|
||||
wall_obj = Mock()
|
||||
wall_obj.matrix_world = Matrix.Identity(4)
|
||||
wall_obj.bound_box = [
|
||||
(-0.1, -0.1, 0.0),
|
||||
(0.1, -0.1, 0.0),
|
||||
(-0.1, 0.1, 0.0),
|
||||
(0.1, 0.1, 0.0),
|
||||
(-0.1, -0.1, 3.0),
|
||||
(0.1, -0.1, 3.0),
|
||||
(-0.1, 0.1, 3.0),
|
||||
(0.1, 0.1, 3.0),
|
||||
]
|
||||
slab_obj = Mock()
|
||||
|
||||
ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0)))
|
||||
with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line):
|
||||
loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
|
||||
|
||||
expected_z = 3.0 + tool.Wall.WALL_SLAB_CONNECTION_Z_CLEARANCE
|
||||
assert loc == Vector((2.0, 0.0, expected_z))
|
||||
|
||||
|
||||
def test_wall_slab_connection_location_returns_none_for_axisless_wall():
|
||||
"""A wall without an IFC Axis representation has no reference line; the
|
||||
helper returns None so callers can skip rather than guess a location."""
|
||||
wall_obj = Mock()
|
||||
slab_obj = Mock()
|
||||
with patch.object(tool.Wall, "get_world_reference_line", return_value=None):
|
||||
assert tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) is None
|
||||
@@ -0,0 +1,67 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch:
|
||||
|
||||
1. Side classification reads the opening's axis-projected midpoint, not
|
||||
the filling's ``matrix_world.translation``. The filling origin is
|
||||
flip-fragile — flipping rotates the filler 180° + translates so the
|
||||
bbox stays visually in place, which would mis-classify a flipped door
|
||||
centred over the cut.
|
||||
2. When the void straddles the cut and the filling moves to element2,
|
||||
the void copy for element1 is taken from the ORIGINAL opening (whose
|
||||
``ObjectPlacement`` still references element1), not the rebound
|
||||
``new_opening`` (whose ``PlacementRelTo`` was swapped to element2)."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _split_source():
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
return inspect.getsource(DumbWallJoiner.split)
|
||||
|
||||
|
||||
def test_side_classification_uses_opening_midpoint_not_filling_origin():
|
||||
"""Side classification must read the opening's axis-projected
|
||||
midpoint, not the filling's world translation — the latter shifts
|
||||
under flipping and would mis-classify a flipped door centred over
|
||||
the cut."""
|
||||
source = _split_source()
|
||||
assert "opening_midpoint" in source
|
||||
assert "filling_obj.matrix_world.translation" not in source
|
||||
|
||||
|
||||
def test_void_copy_reads_from_original_opening_before_remove():
|
||||
"""When the filling moves to element2 and the void straddles the
|
||||
cut, element1's pure-void copy must come from the original opening
|
||||
BEFORE the cleanup that destroys it — the rebound ``new_opening``
|
||||
references element2's frame and would shift the void to element1's
|
||||
origin in element2's local coords."""
|
||||
source = _split_source()
|
||||
branch_start = source.index("if opening_midpoint > cut_percentage:")
|
||||
branch = source[branch_start:]
|
||||
add_idx = branch.index("_add_void_copy(element1, opening)")
|
||||
remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)")
|
||||
assert add_idx < remove_idx
|
||||
@@ -0,0 +1,70 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.patch
|
||||
|
||||
|
||||
class TestExecuteIfcPatchDowngradeEndToEnd(NewFile):
|
||||
"""Drives the full panel flow the user sees: load IFC4 in memory, pick
|
||||
Migrate + IFC2X3, click Execute, get an IFC2X3 file on disk with the
|
||||
expected IfcBuildingElementProxy fallback + ObjectType encoding.
|
||||
|
||||
A regression here means a real user clicking Execute either crashes
|
||||
Blender, produces a broken file, or silently drops type information
|
||||
that the recipe is supposed to preserve via ObjectType."""
|
||||
|
||||
def test_ifc4_with_ifclamp_downgrades_to_ifc2x3_with_proxy_and_object_type(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
ifc.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
|
||||
tool.Ifc.set(ifc)
|
||||
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = True
|
||||
props.ifc_patch_recipes = "Migrate"
|
||||
next(a for a in props.ifc_patch_args_attr if a.name == "Schema").enum_value = "IFC2X3"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_path = Path(tmpdir) / "downgraded.ifc"
|
||||
props.ifc_patch_output = str(output_path)
|
||||
|
||||
result = bpy.ops.bim.execute_ifc_patch()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert output_path.exists(), "Recipe ran but no output file was written"
|
||||
|
||||
written = ifcopenshell.open(str(output_path))
|
||||
assert written.schema == "IFC2X3"
|
||||
proxies = written.by_type("IfcBuildingElementProxy")
|
||||
assert len(proxies) == 1, "IfcLamp should fall back to a single IfcBuildingElementProxy"
|
||||
assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT", (
|
||||
"Original class + PredefinedType must be encoded into ObjectType "
|
||||
"so the downgrade isn't a total information loss"
|
||||
)
|
||||
assert proxies[0].GlobalId == "2K6Z3DR8X37AS9XFvX8GcW"
|
||||
@@ -0,0 +1,131 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.patch
|
||||
|
||||
|
||||
def _set_patch_state(*, recipe: str, target_schema: str | None, source_ifc: ifcopenshell.file | None = None) -> None:
|
||||
"""Drive the BIMPatchProperties into the configuration that a user produces
|
||||
by picking Recipe + Schema in the panel + checking "Load from memory".
|
||||
Setting the recipe fires UpdateIfcPatchArguments which builds the dynamic
|
||||
args collection — only then can we assign the schema arg's enum_value."""
|
||||
props = tool.Patch.get_patch_props()
|
||||
if source_ifc is not None:
|
||||
tool.Ifc.set(source_ifc)
|
||||
props.should_load_from_memory = True
|
||||
props.ifc_patch_recipes = recipe # update callback builds ifc_patch_args_attr
|
||||
if target_schema is not None:
|
||||
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
|
||||
schema_arg.enum_value = target_schema
|
||||
|
||||
|
||||
class TestMigrationIsLossyDowngrade(NewFile):
|
||||
"""Pins the predicate that gates ``ExecuteIfcPatch.invoke``'s
|
||||
confirmation popup. Every row of the truth table corresponds to a real
|
||||
user-facing flow — wrong answers either nag the user on safe migrations
|
||||
or silently let lossy ones through with no warning."""
|
||||
|
||||
def test_ifc4_to_ifc2x3_in_memory_is_lossy(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is True
|
||||
|
||||
def test_ifc4x3_to_ifc2x3_in_memory_is_lossy(self):
|
||||
# Regression for the gate that originally only fired for self.file.schema == "IFC4",
|
||||
# silently leaving IFC4X3 sources crashing on IFC4-only geometry.
|
||||
ifc = ifcopenshell.file(schema="IFC4X3")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is True
|
||||
|
||||
def test_ifc2x3_to_ifc4_upgrade_is_not_lossy(self):
|
||||
ifc = ifcopenshell.file(schema="IFC2X3")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
def test_ifc4_to_ifc4_same_schema_is_not_lossy(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
def test_non_migrate_recipe_is_not_lossy(self):
|
||||
# The popup only ever applies to the Migrate recipe — other recipes
|
||||
# (ExtractElements, TessellateElements, …) handle their own warnings.
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
_set_patch_state(recipe="ExtractElements", target_schema=None, source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
def test_no_source_set_is_not_lossy(self):
|
||||
# Without an input file or in-memory IFC, the predicate cannot tell
|
||||
# what the source schema is — defaults to False so the popup doesn't
|
||||
# block harmless cases where the user is still configuring the panel.
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.ifc_patch_recipes = "Migrate"
|
||||
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
|
||||
schema_arg.enum_value = "IFC2X3"
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
|
||||
class TestPatchSourceSchemaSniff(NewFile):
|
||||
"""End-to-end pin on the header-only schema parsing. The IFC4X3 misdetection
|
||||
bug originally lived in this code path — a raw startswith(\"IFC4\") loop
|
||||
matching IFC4X3_ADD2 before the IFC4X3 base check was reached."""
|
||||
|
||||
def test_in_memory_ifc4x3_source_resolves_to_ifc4x3(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4X3")
|
||||
tool.Ifc.set(ifc)
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = True
|
||||
assert tool.Patch._patch_source_schema() == "IFC4X3"
|
||||
|
||||
def test_file_path_ifc4x3_add2_source_resolves_to_ifc4x3(self):
|
||||
# Writes a real .ifc file with IFC4X3_ADD2 in the FILE_SCHEMA header
|
||||
# and confirms the regex + get_fallback_schema normaliser correctly
|
||||
# collapse it to IFC4X3, not IFC4.
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = Path(tmpdir) / "sample.ifc"
|
||||
ifc_path.write_text(
|
||||
"ISO-10303-21;\n"
|
||||
"HEADER;\n"
|
||||
"FILE_DESCRIPTION((''),'2;1');\n"
|
||||
"FILE_NAME('','2026',(''),(''),'','','');\n"
|
||||
"FILE_SCHEMA(('IFC4X3_ADD2'));\n"
|
||||
"ENDSEC;\n"
|
||||
"DATA;\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||
)
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = False
|
||||
props.ifc_patch_input = str(ifc_path)
|
||||
assert tool.Patch._patch_source_schema() == "IFC4X3"
|
||||
|
||||
def test_missing_input_returns_empty_string(self):
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = False
|
||||
props.ifc_patch_input = ""
|
||||
assert tool.Patch._patch_source_schema() == ""
|
||||
@@ -0,0 +1,55 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.patch
|
||||
|
||||
|
||||
class TestPresetMenuLabelResetsOnRecipeChange(NewFile):
|
||||
"""Blender's ``script.execute_preset`` mutates the menu class's bl_label
|
||||
to the loaded preset's display name as a "currently-selected" indicator.
|
||||
Without a recipe-change callback, that label persists into the next
|
||||
recipe's menu — falsely advertising a preset that belongs to a
|
||||
different recipe's subdir and isn't selectable from the new menu."""
|
||||
|
||||
def test_changing_recipe_restores_canonical_label(self):
|
||||
# Simulate the state Blender leaves after the user picked a preset
|
||||
# for the previous recipe.
|
||||
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
|
||||
menu_cls.bl_label = "Structural"
|
||||
|
||||
# Switching the recipe must fire update_ifc_patch_recipe, which
|
||||
# resets the menu label.
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.ifc_patch_recipes = "Migrate"
|
||||
|
||||
assert menu_cls.bl_label == "IFC Patch Presets"
|
||||
|
||||
def test_canonical_label_is_used_when_no_preset_was_loaded(self):
|
||||
# Fresh state — label is the bl_label-default from the class declaration.
|
||||
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.ifc_patch_recipes = "ExtractElements"
|
||||
assert menu_cls.bl_label == "IFC Patch Presets"
|
||||
@@ -0,0 +1,121 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.root
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.project.data import ProjectLibraryData
|
||||
from test.bim.bootstrap import NewIfc
|
||||
|
||||
pytestmark = pytest.mark.project
|
||||
|
||||
|
||||
def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file:
|
||||
"""Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject).
|
||||
|
||||
Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a
|
||||
valid root on its own. ``with_child=True`` nests a sub-library under the root via
|
||||
IfcRelNests, mirroring real authored library files.
|
||||
"""
|
||||
library_file = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
|
||||
if with_child:
|
||||
child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib")
|
||||
ifcopenshell.api.nest.assign_object(library_file, [child], root)
|
||||
return library_file
|
||||
|
||||
|
||||
class TestLibraryOnlyFile(NewIfc):
|
||||
def test_get_root_context_returns_project_library_when_no_project(self):
|
||||
library_file = _make_library_only_file()
|
||||
assert not library_file.by_type("IfcProject")
|
||||
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
|
||||
assert root.is_a("IfcProjectLibrary")
|
||||
assert root.Name == "RootLib"
|
||||
|
||||
def test_get_parent_library_returns_none_for_root_library(self):
|
||||
library_file = _make_library_only_file()
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
|
||||
assert tool.Project.get_parent_library(root) is None
|
||||
|
||||
def test_get_project_hierarchy_skips_root_library(self):
|
||||
library_file = _make_library_only_file(with_child=True)
|
||||
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
|
||||
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
|
||||
|
||||
hierarchy = tool.Project.get_project_hierarchy(library_file)
|
||||
|
||||
assert root in hierarchy
|
||||
assert child in hierarchy[root]
|
||||
|
||||
def test_project_library_data_loads_without_crash(self):
|
||||
IfcStore.library_file = _make_library_only_file()
|
||||
try:
|
||||
ProjectLibraryData.is_loaded = False
|
||||
ProjectLibraryData.load()
|
||||
assert ProjectLibraryData.is_loaded
|
||||
enum = ProjectLibraryData.data["parent_libraries_enum"]
|
||||
assert len(enum) == 1
|
||||
assert enum[0][1].startswith("IfcProjectLibrary ")
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_refresh_library_succeeds_on_library_only_file(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_only_file(with_child=True)
|
||||
try:
|
||||
result = bpy.ops.bim.refresh_library()
|
||||
assert result == {"FINISHED"}
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_add_project_library_nests_under_root_when_no_project(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_only_file()
|
||||
library_file = IfcStore.library_file
|
||||
try:
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
before = set(library_file.by_type("IfcProjectLibrary"))
|
||||
|
||||
result = bpy.ops.bim.add_project_library()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
after = set(library_file.by_type("IfcProjectLibrary"))
|
||||
new_libraries = after - before
|
||||
assert len(new_libraries) == 1
|
||||
new_library = next(iter(new_libraries))
|
||||
assert new_library.Nests
|
||||
assert new_library.Nests[0].RelatingObject == root
|
||||
assert not new_library.HasContext
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
@@ -0,0 +1,104 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contracts for the class-mismatched-type-assignment guard.
|
||||
|
||||
Two structural invariants that no behavioural test can pin on its own:
|
||||
|
||||
1. ``ifcopenshell.api.type.assign_type`` MUST reference
|
||||
``ifcopenshell.util.type.get_applicable_entities`` (or
|
||||
``get_applicable_types``) — the schema-aware applicability lookup that
|
||||
produces the canonical class-pairing whitelist. A drift here means the
|
||||
API stops rejecting class-mismatched pairs.
|
||||
|
||||
2. ``bonsai.bim.module.type.operator`` MUST reference
|
||||
``tool.Type.is_relating_type_compatible`` — the single source of truth
|
||||
for partition / WARNING / CANCELLED behaviour in the Bonsai operator
|
||||
layer. A drift here re-opens the fan-out hole that silently writes
|
||||
schema-corrupt typings into the selection when one (active) object's
|
||||
class drove the picker but other selected objects don't match.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.type
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).resolve().parents[4] / "bonsai"
|
||||
IFCOPENSHELL_API_ASSIGN_TYPE = (
|
||||
Path(__file__).resolve().parents[5] / "ifcopenshell-python" / "ifcopenshell" / "api" / "type" / "assign_type.py"
|
||||
)
|
||||
BONSAI_TYPE_OPERATOR = BONSAI_ROOT / "bim" / "module" / "type" / "operator.py"
|
||||
|
||||
|
||||
def _attribute_chain(node: ast.AST) -> str:
|
||||
"""Render an ``ast.Attribute``/``ast.Name`` chain as a dotted string,
|
||||
e.g. ``tool.Type.is_relating_type_compatible``. Returns ``""`` if the
|
||||
chain bottoms out on something other than a Name (e.g. a subscript)."""
|
||||
parts: list[str] = []
|
||||
while isinstance(node, ast.Attribute):
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
if isinstance(node, ast.Name):
|
||||
parts.append(node.id)
|
||||
return ".".join(reversed(parts))
|
||||
return ""
|
||||
|
||||
|
||||
def _all_attribute_chains(tree: ast.Module) -> set[str]:
|
||||
chains: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute):
|
||||
chain = _attribute_chain(node)
|
||||
if chain:
|
||||
chains.add(chain)
|
||||
return chains
|
||||
|
||||
|
||||
def test_api_assign_type_calls_applicability_lookup() -> None:
|
||||
"""Pin Layer B: ``ifcopenshell.api.type.assign_type`` references
|
||||
``ifcopenshell.util.type.get_applicable_entities`` (the source of truth
|
||||
for which occurrence classes a given type class may type)."""
|
||||
tree = ast.parse(IFCOPENSHELL_API_ASSIGN_TYPE.read_text(encoding="utf-8"))
|
||||
chains = _all_attribute_chains(tree)
|
||||
sentinel = "ifcopenshell.util.type.get_applicable_entities"
|
||||
assert sentinel in chains, (
|
||||
f"{IFCOPENSHELL_API_ASSIGN_TYPE.name} no longer references {sentinel}. "
|
||||
"The API-layer guard against class-mismatched type assignment is gone."
|
||||
)
|
||||
|
||||
|
||||
def test_bonsai_type_operator_module_references_compatibility_helper() -> None:
|
||||
"""Pin Layer C: the Bonsai type operator module references
|
||||
``tool.Type.is_relating_type_compatible``. Every operator in this file
|
||||
that fans assign_type calls across a multi-selection must filter
|
||||
through this helper to avoid writing mismatched typings on objects the
|
||||
panel picker didn't validate."""
|
||||
tree = ast.parse(BONSAI_TYPE_OPERATOR.read_text(encoding="utf-8"))
|
||||
chains = _all_attribute_chains(tree)
|
||||
sentinel = "tool.Type.is_relating_type_compatible"
|
||||
assert sentinel in chains, (
|
||||
f"{BONSAI_TYPE_OPERATOR.name} no longer references {sentinel}. "
|
||||
"Operator-layer partition that prevents schema-illegal type "
|
||||
"assignment across multi-selection has been removed."
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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 Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Contract test for BIM_OT_assign_type's per-object class-compatibility
|
||||
partition.
|
||||
|
||||
When the user multi-selects mixed classes (e.g. a wall + a door), the panel
|
||||
picker filters the class dropdown by the active object's class only.
|
||||
Historically the operator then fanned out across the whole selection without
|
||||
re-checking each occurrence, producing schema-corrupt IFC files (IfcDoor
|
||||
typed by IfcWallType). The partition added in this change must:
|
||||
|
||||
1. Assign the type only to compatible occurrences.
|
||||
2. Surface skipped classes through ``self.report({'WARNING'}, ...)``.
|
||||
3. ``return {'CANCELLED'}`` and emit an ERROR when nothing in the selection
|
||||
is compatible — no mutation must reach ``core.assign_type``.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.type
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
import types as _types
|
||||
|
||||
import bpy
|
||||
|
||||
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_ifc():
|
||||
import ifcopenshell
|
||||
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
previous = IfcStore.file
|
||||
IfcStore.file = ifcopenshell.file(schema="IFC4")
|
||||
try:
|
||||
yield IfcStore.file
|
||||
finally:
|
||||
IfcStore.file = previous
|
||||
|
||||
|
||||
def _make_object(name, element):
|
||||
"""Build a real bpy.types.Object linked to an IFC entity via
|
||||
tool.Ifc.link, so tool.Ifc.get_entity(obj) resolves correctly."""
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
obj = bpy.data.objects.new(name, None)
|
||||
tool.Ifc.link(element, obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _execute_assign(op, context):
|
||||
"""Drive ``AssignType._execute`` directly. Bypasses the framework's
|
||||
transaction wrapping so a unit test can observe the partition without
|
||||
setting up the full Blender harness."""
|
||||
return op._execute(context)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neutralised_side_effects():
|
||||
"""Patch the helpers ``AssignType._execute`` calls outside the partition
|
||||
logic (addon prefs, drawing context lookup, drawing target-view branch),
|
||||
so the test asserts only the partition / report / return-code contract."""
|
||||
with mock.patch("bonsai.bim.module.type.operator.tool.Blender.get_addon_preferences") as prefs:
|
||||
prefs.return_value = mock.Mock(occurrence_name_style="OCCURRENCE")
|
||||
yield
|
||||
|
||||
|
||||
def _build_context_with_no_active_drawing():
|
||||
"""Return a Mock ``context`` whose ``scene.DocProperties.active_drawing_id``
|
||||
is 0, skipping the drawing-target-view block in ``_execute``."""
|
||||
context = mock.Mock()
|
||||
context.scene.DocProperties.active_drawing_id = 0
|
||||
return context
|
||||
|
||||
|
||||
def _fake_operator_with_report():
|
||||
"""Build a Mock that satisfies the attribute reads ``AssignType._execute``
|
||||
makes on ``self`` (``relating_type``, ``related_object``, ``report``)."""
|
||||
op = mock.MagicMock()
|
||||
op.relating_type = 0
|
||||
op.related_object = ""
|
||||
op.report = mock.Mock()
|
||||
return op
|
||||
|
||||
|
||||
def test_mixed_selection_assigns_only_compatible_objects(fresh_ifc, neutralised_side_effects):
|
||||
"""Wall + door selected, IfcWallType picked: wall gets typed, door is
|
||||
skipped with a WARNING, and the operator returns success.
|
||||
|
||||
``core.assign_type`` is mocked: it would otherwise run the
|
||||
representation-switch and material plumbing on stub Blender objects.
|
||||
The contract under test is the partition / report logic, not the
|
||||
downstream representation pipeline."""
|
||||
import ifcopenshell.api.root
|
||||
|
||||
from bonsai.bim.module.type.operator import AssignType
|
||||
|
||||
wall_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWall")
|
||||
door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
|
||||
wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
|
||||
|
||||
wall_obj = _make_object("Wall", wall_elem)
|
||||
door_obj = _make_object("Door", door_elem)
|
||||
|
||||
op = _fake_operator_with_report()
|
||||
op.relating_type = wall_type.id()
|
||||
|
||||
with mock.patch(
|
||||
"bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[wall_obj, door_obj]
|
||||
), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
|
||||
result = AssignType._execute(op, _build_context_with_no_active_drawing())
|
||||
|
||||
assert result != {"CANCELLED"}, "operator must succeed when at least one object is compatible"
|
||||
typed_elements = {call.kwargs["element"] for call in mock_assign.call_args_list}
|
||||
assert typed_elements == {
|
||||
wall_elem
|
||||
}, f"only the compatible wall element should reach core.assign_type, got {typed_elements}"
|
||||
|
||||
warning_calls = [c for c in op.report.call_args_list if c.args[0] == {"WARNING"}]
|
||||
assert warning_calls, "skipped occurrence class must surface as a WARNING"
|
||||
assert any("IfcDoor" in c.args[1] for c in warning_calls)
|
||||
|
||||
|
||||
def test_all_incompatible_selection_returns_cancelled_without_mutation(fresh_ifc, neutralised_side_effects):
|
||||
"""Door alone selected, IfcWallType picked: nothing to assign. Operator
|
||||
must return CANCELLED, emit an ERROR, and never call core.assign_type."""
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.util.element
|
||||
|
||||
from bonsai.bim.module.type.operator import AssignType
|
||||
|
||||
door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
|
||||
wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
|
||||
door_obj = _make_object("Door", door_elem)
|
||||
|
||||
op = _fake_operator_with_report()
|
||||
op.relating_type = wall_type.id()
|
||||
|
||||
with mock.patch(
|
||||
"bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[door_obj]
|
||||
), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
|
||||
result = AssignType._execute(op, _build_context_with_no_active_drawing())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
assert mock_assign.call_count == 0
|
||||
error_calls = [c for c in op.report.call_args_list if c.args[0] == {"ERROR"}]
|
||||
assert error_calls, "all-incompatible selection must surface as an ERROR"
|
||||
assert ifcopenshell.util.element.get_type(door_elem) is None
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Dispatch tests for ``core.connection.disconnect_rel``.
|
||||
|
||||
The dispatch is the single source of truth for per-kind cleanup shared by the
|
||||
explicit ``bim.disconnect_elements`` operator and the implicit cascade in
|
||||
``tool.Geometry.delete_ifc_object``. Each kind has one test that pins which
|
||||
helpers must be called; the AST forward-compat guard in
|
||||
``test_connection_forward_compat.py`` then asserts the dispatch table covers
|
||||
every kind ``Connection.find_rels`` can emit.
|
||||
|
||||
Uses ``unittest.mock`` directly (rather than the Prophecy fixtures) because
|
||||
the dispatch passes IFC rel entities with attribute access (``rel.RelatingElement``)
|
||||
that Prophecy's JSON call recorder can't serialize.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.core.connection as subject
|
||||
|
||||
|
||||
def _rel(relating="slab", related="wall"):
|
||||
return SimpleNamespace(RelatingElement=relating, RelatedElement=related)
|
||||
|
||||
|
||||
def _ifc_with_objects(mapping):
|
||||
ifc = Mock()
|
||||
ifc.get_object.side_effect = lambda e: mapping.get(e)
|
||||
ifc.run = Mock()
|
||||
return ifc
|
||||
|
||||
|
||||
class TestDisconnectRelPath:
|
||||
def test_removes_connection_and_recreates_both_walls(self):
|
||||
ifc = _ifc_with_objects({"elem_a": "obj_a", "elem_b": "obj_b"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
model,
|
||||
connection,
|
||||
subject="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,
|
||||
subject="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,
|
||||
subject="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,
|
||||
subject="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,
|
||||
subject=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,
|
||||
subject=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,
|
||||
subject=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,
|
||||
subject=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(),
|
||||
subject=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 TestDisconnectRelMEPPairFitting:
|
||||
"""The ``mep-pair-fitting`` kind treats the rel slot as the fitting whose
|
||||
removal disconnects the pair — deletion routes through
|
||||
``geometry.delete_ifc_object`` so the cascade-on-delete contract still
|
||||
owns port-rel cleanup."""
|
||||
|
||||
def test_deletes_fitting_via_delete_ifc_object(self):
|
||||
fitting = Mock(name="fitting")
|
||||
fitting_obj = Mock(name="fitting_obj")
|
||||
ifc = _ifc_with_objects({fitting: fitting_obj})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem="seg_a",
|
||||
partner="seg_b",
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_called_once_with(fitting_obj)
|
||||
|
||||
def test_noops_when_fitting_has_no_blender_object(self):
|
||||
"""Defensive: a fitting with no bound Blender object can't be
|
||||
deleted via ``delete_ifc_object``; the dispatch must not crash."""
|
||||
fitting = Mock(name="fitting")
|
||||
ifc = _ifc_with_objects({})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem="seg_a",
|
||||
partner="seg_b",
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_not_called()
|
||||
|
||||
def test_skip_elem_recreate_suppresses_delete_when_fitting_is_elem(self):
|
||||
"""Cascade case: the fitting is itself the element being deleted
|
||||
— don't try to delete it twice."""
|
||||
fitting = Mock(name="fitting")
|
||||
ifc = _ifc_with_objects({fitting: Mock()})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem=fitting,
|
||||
partner="other",
|
||||
skip_elem_recreate=True,
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_not_called()
|
||||
|
||||
def test_skip_partner_recreate_suppresses_delete_when_fitting_is_partner(self):
|
||||
fitting = Mock(name="fitting")
|
||||
ifc = _ifc_with_objects({fitting: Mock()})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem="seg_a",
|
||||
partner=fitting,
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_not_called()
|
||||
|
||||
|
||||
class TestDisconnectRelUnknownKind:
|
||||
def test_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Unknown kind"):
|
||||
subject.disconnect_rel(
|
||||
Mock(),
|
||||
Mock(),
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject="rel",
|
||||
kind="bogus",
|
||||
elem="a",
|
||||
partner="b",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from mathutils import Vector
|
||||
import math
|
||||
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from bonsai.tool.cad import Cad as subject
|
||||
from test.bim.bootstrap import NewFile
|
||||
@@ -88,3 +90,87 @@ class TestClosestPoints(NewFile):
|
||||
edge1 = (V(0, 0, 0), V(0, 0, 0))
|
||||
edge2 = (V(1, 0, 1), V(2, 0, 2))
|
||||
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0])
|
||||
|
||||
|
||||
class TestObbWorldClipPlanes(NewFile):
|
||||
def test_unit_box_at_origin_returns_axis_aligned_planes(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert planes[0] == (-1.0, 0.0, 0.0, 1.0)
|
||||
assert planes[1] == (1.0, 0.0, 0.0, 1.0)
|
||||
assert planes[2] == (0.0, -1.0, 0.0, 1.0)
|
||||
assert planes[3] == (0.0, 1.0, 0.0, 1.0)
|
||||
assert planes[4] == (0.0, 0.0, -1.0, 1.0)
|
||||
assert planes[5] == (0.0, 0.0, 1.0, 1.0)
|
||||
|
||||
def test_center_is_inside_all_planes(self):
|
||||
center = V(5, -3, 2)
|
||||
planes = subject.obb_world_clip_planes(
|
||||
center,
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(2, 1, 0.5),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, center)
|
||||
|
||||
def test_point_just_outside_positive_x_face_rejected(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0.5, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.5, 0, 0))
|
||||
|
||||
def test_rotated_obb_clips_along_rotated_axes(self):
|
||||
s = math.sin(math.radians(45))
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(s, s, 0), V(-s, s, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||
|
||||
def test_zero_extent_axis_does_not_raise(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 0),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
|
||||
|
||||
class TestObbClipPlanesFromMatrix(NewFile):
|
||||
def test_identity_matches_unit_box(self):
|
||||
planes = subject.obb_clip_planes_from_matrix(Matrix.Identity(4))
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, -2, 0))
|
||||
|
||||
def test_translated_host_shifts_clip_region(self):
|
||||
translated = Matrix.Translation(V(10, 0, 0))
|
||||
planes = subject.obb_clip_planes_from_matrix(translated)
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
assert subject.point_is_inside_clip_planes(planes, V(10, 0, 0))
|
||||
|
||||
def test_z_rotation_rotates_box(self):
|
||||
rot = Matrix.Rotation(math.radians(45), 4, "Z")
|
||||
planes = subject.obb_clip_planes_from_matrix(rot)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||
|
||||
def test_host_scale_scales_box_extents(self):
|
||||
scaled = Matrix.Diagonal((2.0, 2.0, 2.0, 1.0))
|
||||
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.9, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(2.1, 0, 0))
|
||||
|
||||
def test_non_uniform_scale_axis_independent(self):
|
||||
scaled = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
|
||||
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(2.9, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(3.1, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, 1.1, 0))
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import math
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.type
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0):
|
||||
"""Real bpy cube + ifc entity, linked. ``size`` is the cube edge length."""
|
||||
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
|
||||
obj = bpy.context.active_object
|
||||
entity = ifc.create_entity(ifc_class)
|
||||
tool.Ifc.link(entity, obj)
|
||||
return entity, obj
|
||||
|
||||
|
||||
class TestWorldBboxMatrix(NewFile):
|
||||
def test_two_cubes_returns_centred_aabb_matrix(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
|
||||
|
||||
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b])
|
||||
|
||||
assert matrix is not None
|
||||
translation, _, scale = matrix.decompose()
|
||||
# World AABB: x in [-1, 5], y/z in [-1, 1] -> center (2, 0, 0), half (3, 1, 1).
|
||||
assert translation.x == pytest.approx(2.0)
|
||||
assert translation.y == pytest.approx(0.0)
|
||||
assert translation.z == pytest.approx(0.0)
|
||||
assert scale.x == pytest.approx(3.0)
|
||||
assert scale.y == pytest.approx(1.0)
|
||||
assert scale.z == pytest.approx(1.0)
|
||||
|
||||
def test_empty_iterable_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox._world_bbox_matrix_for_elements([]) is None
|
||||
|
||||
def test_element_without_blender_object_is_skipped(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
unbound = ifc.create_entity("IfcWall")
|
||||
|
||||
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, unbound])
|
||||
|
||||
assert matrix is not None
|
||||
translation, _, scale = matrix.decompose()
|
||||
assert translation.x == pytest.approx(0.0)
|
||||
assert translation.y == pytest.approx(0.0)
|
||||
assert translation.z == pytest.approx(0.0)
|
||||
assert scale.x == pytest.approx(1.0)
|
||||
|
||||
def test_all_filtered_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
unbound_a = ifc.create_entity("IfcWall")
|
||||
unbound_b = ifc.create_entity("IfcWall")
|
||||
assert tool.ClipBox._world_bbox_matrix_for_elements([unbound_a, unbound_b]) is None
|
||||
|
||||
def test_coincident_cubes_return_invertible_matrix(self):
|
||||
# Two cubes at the same location collapse to a zero-volume AABB.
|
||||
# The half-extent floor must keep the matrix invertible so downstream
|
||||
# clip-plane math doesn't divide through a singular transform.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001)
|
||||
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001)
|
||||
|
||||
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b])
|
||||
|
||||
assert matrix is not None
|
||||
# A zero determinant means the matrix would map every point onto a
|
||||
# subspace — the floor must prevent that.
|
||||
assert matrix.determinant() != 0.0
|
||||
|
||||
|
||||
class TestCameraFrustumMatrix(NewFile):
|
||||
def _make_camera(self, location=(0.0, 0.0, 0.0), rotation=None):
|
||||
cam_data = bpy.data.cameras.new("DrawingCam")
|
||||
cam_data.type = "ORTHO"
|
||||
obj = bpy.data.objects.new("DrawingCam", cam_data)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
obj.location = location
|
||||
if rotation is not None:
|
||||
obj.rotation_euler = rotation
|
||||
bpy.context.view_layer.update()
|
||||
return obj
|
||||
|
||||
def test_identity_camera_width_height_drive_in_plane_extents(self):
|
||||
obj = self._make_camera()
|
||||
cam = obj.data
|
||||
cam.clip_start = 0.0
|
||||
cam.clip_end = 10.0
|
||||
cam.BIMCameraProperties.width = 8.0
|
||||
cam.BIMCameraProperties.height = 6.0
|
||||
|
||||
matrix = tool.ClipBox._camera_frustum_matrix(obj)
|
||||
|
||||
translation, _, scale = matrix.decompose()
|
||||
# Identity rotation: box centre at (0, 0, -5) in world (cameras look down -Z).
|
||||
assert translation.x == pytest.approx(0.0)
|
||||
assert translation.y == pytest.approx(0.0)
|
||||
assert translation.z == pytest.approx(-5.0)
|
||||
# Half-extents: width/2, height/2, (clip_end - clip_start) / 2.
|
||||
assert scale.x == pytest.approx(4.0)
|
||||
assert scale.y == pytest.approx(3.0)
|
||||
assert scale.z == pytest.approx(5.0)
|
||||
|
||||
def test_rotated_camera_preserves_rotation_in_matrix(self):
|
||||
obj = self._make_camera(rotation=(0.0, math.radians(90), 0.0))
|
||||
cam = obj.data
|
||||
cam.clip_start = 0.0
|
||||
cam.clip_end = 4.0
|
||||
cam.BIMCameraProperties.width = 2.0
|
||||
cam.BIMCameraProperties.height = 2.0
|
||||
|
||||
matrix = tool.ClipBox._camera_frustum_matrix(obj)
|
||||
|
||||
_, rotation, scale = matrix.decompose()
|
||||
# Scale is rotation-invariant.
|
||||
assert scale.x == pytest.approx(1.0)
|
||||
assert scale.y == pytest.approx(1.0)
|
||||
assert scale.z == pytest.approx(2.0)
|
||||
# The rotation component matches the camera's own rotation; quaternion
|
||||
# dot product near unit magnitude means the orientations agree.
|
||||
cam_rot = obj.matrix_world.decompose()[1]
|
||||
assert abs(cam_rot.dot(rotation)) > 0.999
|
||||
|
||||
def test_returns_none_when_width_height_zero(self):
|
||||
# A camera without usable drawing extents (width or height ≤ 0)
|
||||
# cannot define a clip volume — caller surfaces ERROR + CANCELLED.
|
||||
obj = self._make_camera()
|
||||
cam = obj.data
|
||||
cam.clip_start = 0.0
|
||||
cam.clip_end = 10.0
|
||||
cam.BIMCameraProperties.width = 8.0
|
||||
# height stays at the BIMCameraProperties default (50). We can't set
|
||||
# height=0 here because the update callback divides width/height.
|
||||
# Set width=0 directly via the underlying ID property instead, which
|
||||
# bypasses the registered FloatProperty update path.
|
||||
cam.BIMCameraProperties["width"] = 0.0
|
||||
|
||||
assert tool.ClipBox._camera_frustum_matrix(obj) is None
|
||||
|
||||
|
||||
class TestIterElementsForSource(NewFile):
|
||||
def test_no_ifc_file_returns_empty(self):
|
||||
# NewFile leaves IfcStore purged; tool.Ifc.get() is None here.
|
||||
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "1") == []
|
||||
|
||||
def test_unknown_kind_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall = ifc.create_entity("IfcWall")
|
||||
assert tool.ClipBox.iter_elements_for_source("UNKNOWN_KIND", str(wall.id())) == []
|
||||
|
||||
def test_non_integer_source_id_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "not_an_int") == []
|
||||
|
||||
def test_unresolved_source_id_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "999999") == []
|
||||
|
||||
def test_spatial_returns_decomposition(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
storey = ifc.create_entity("IfcBuildingStorey")
|
||||
wall_a = ifc.create_entity("IfcWall")
|
||||
wall_b = ifc.create_entity("IfcWall")
|
||||
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("SPATIAL", str(storey.id()))
|
||||
|
||||
assert set(result) == {wall_a, wall_b}
|
||||
|
||||
def test_type_returns_occurrences(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_type = ifc.create_entity("IfcWallType")
|
||||
wall_a = ifc.create_entity("IfcWall")
|
||||
wall_b = ifc.create_entity("IfcWall")
|
||||
ifcopenshell.api.type.assign_type(ifc, related_objects=[wall_a, wall_b], relating_type=wall_type)
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("TYPE", str(wall_type.id()))
|
||||
|
||||
assert set(result) == {wall_a, wall_b}
|
||||
|
||||
def test_drawing_returns_drawing_entity(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("DRAWING", str(drawing.id()))
|
||||
|
||||
assert result == [drawing]
|
||||
|
||||
def test_status_invalid_value_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox.iter_elements_for_source("STATUS", "MADE_UP_STATUS") == []
|
||||
|
||||
def test_class_returns_all_instances_of_ifc_class(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a = ifc.create_entity("IfcWall")
|
||||
wall_b = ifc.create_entity("IfcWall")
|
||||
window = ifc.create_entity("IfcWindow")
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("CLASS", "IfcWall")
|
||||
|
||||
assert set(result) == {wall_a, wall_b}
|
||||
assert window not in result
|
||||
|
||||
def test_class_unknown_ifc_class_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifc.create_entity("IfcWall")
|
||||
assert tool.ClipBox.iter_elements_for_source("CLASS", "IfcNotARealClass") == []
|
||||
|
||||
|
||||
class TestComputeMatrixForSource(NewFile):
|
||||
def test_spatial_aggregates_contained_elements(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
storey = ifc.create_entity("IfcBuildingStorey")
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
|
||||
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
|
||||
|
||||
matrix = tool.ClipBox.compute_matrix_for_source("SPATIAL", str(storey.id()))
|
||||
|
||||
assert matrix is not None
|
||||
translation, _, scale = matrix.decompose()
|
||||
assert translation.x == pytest.approx(2.0)
|
||||
assert scale.x == pytest.approx(3.0)
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_type = ifc.create_entity("IfcWallType")
|
||||
# No occurrences linked.
|
||||
assert tool.ClipBox.compute_matrix_for_source("TYPE", str(wall_type.id())) is None
|
||||
|
||||
def test_drawing_uses_camera_frustum(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
|
||||
cam_data = bpy.data.cameras.new("Cam")
|
||||
cam_data.type = "ORTHO"
|
||||
cam_data.clip_start = 0.0
|
||||
cam_data.clip_end = 10.0
|
||||
cam_data.BIMCameraProperties.width = 4.0
|
||||
cam_data.BIMCameraProperties.height = 4.0
|
||||
obj = bpy.data.objects.new("Cam", cam_data)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(drawing, obj)
|
||||
|
||||
matrix = tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id()))
|
||||
|
||||
assert matrix is not None
|
||||
_, _, scale = matrix.decompose()
|
||||
assert scale.x == pytest.approx(2.0)
|
||||
assert scale.y == pytest.approx(2.0)
|
||||
assert scale.z == pytest.approx(5.0)
|
||||
|
||||
def test_drawing_with_non_camera_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
|
||||
obj = bpy.data.objects.new("NotACamera", None)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(drawing, obj)
|
||||
|
||||
assert tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id())) is None
|
||||
|
||||
def test_status_with_no_matching_elements_returns_none(self):
|
||||
# STATUS pick with a valid status value but no element carrying that
|
||||
# status — the dispatcher must surface "nothing matched" the same way
|
||||
# an empty TYPE / MATERIAL pick does.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
# Create a wall but never assign its Pset_WallCommon.Status — so a
|
||||
# STATUS=NEW query finds 0 elements.
|
||||
_make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
|
||||
assert tool.ClipBox.compute_matrix_for_source("STATUS", "NEW") is None
|
||||
|
||||
|
||||
class _FakeRegion:
|
||||
def __init__(self, width, height):
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
|
||||
class _FakeRV3D:
|
||||
def __init__(self, view_matrix=()): # () is a truthy-enough non-None stand-in
|
||||
self.view_matrix = view_matrix
|
||||
self.updated = False
|
||||
self.use_clip_planes = False
|
||||
self.clip_planes = None
|
||||
|
||||
def update(self):
|
||||
self.updated = True
|
||||
|
||||
|
||||
class TestRegionIsRenderable:
|
||||
"""``_region_is_renderable`` gates the clip-plane arm against collapsed /
|
||||
initializing regions whose ``region_3d.update()`` would CTD Blender inside
|
||||
``GPU_matrix_ortho_set`` (the timer-arm crash this guard fixes)."""
|
||||
|
||||
def test_sized_region_with_view_matrix_is_renderable(self):
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), _FakeRV3D()) is True
|
||||
|
||||
def test_zero_width_is_not_renderable(self):
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(0, 600), _FakeRV3D()) is False
|
||||
|
||||
def test_zero_height_is_not_renderable(self):
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 0), _FakeRV3D()) is False
|
||||
|
||||
def test_missing_view_matrix_is_not_renderable(self):
|
||||
rv3d = _FakeRV3D()
|
||||
rv3d.view_matrix = None
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), rv3d) is False
|
||||
|
||||
def test_arm_region_early_returns_on_zero_size(self):
|
||||
# A collapsed region must never reach temp_override / clip_border /
|
||||
# update() — _arm_region short-circuits at the size guard. Positively
|
||||
# assert update() was NOT called and no clip state was written, so a
|
||||
# regression that drops the guard fails here rather than passing on
|
||||
# "didn't crash".
|
||||
rv3d = _FakeRV3D()
|
||||
tool.ClipBox._arm_region(object(), _FakeRegion(0, 0), rv3d, ())
|
||||
assert rv3d.updated is False
|
||||
assert rv3d.use_clip_planes is False
|
||||
assert rv3d.clip_planes is None
|
||||
@@ -0,0 +1,123 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a
|
||||
branch for every ``kind`` emitted by ``tool.connection.Connection`` lookups.
|
||||
|
||||
Adding a new 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 kind")``
|
||||
fallback, and either crash the operator or leave the cascade half-done. This
|
||||
guard makes the symmetry mandatory at test time."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py"
|
||||
CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py"
|
||||
|
||||
|
||||
def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise AssertionError(f"Function {name!r} not found")
|
||||
|
||||
|
||||
def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.FunctionDef) and child.name == method_name:
|
||||
return child
|
||||
raise AssertionError(f"Method {class_name}.{method_name} not found")
|
||||
|
||||
|
||||
def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]:
|
||||
"""Extract every kind label this method emits.
|
||||
|
||||
Looks at exactly two narrow patterns to avoid false positives from
|
||||
docstrings or type-annotation strings:
|
||||
|
||||
- ``_record(rel, "<kind>", …)`` — positional string at index 1, the
|
||||
conventional emit shape in ``find_rels`` / ``find_rels_for_element``.
|
||||
- ``kind = "<a>" if … else "<b>"`` and chained variants — string
|
||||
literals on either branch of an ``ast.IfExp`` assigned to ``kind``.
|
||||
"""
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(method):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2:
|
||||
arg = node.args[1]
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
||||
kinds.add(arg.value)
|
||||
elif isinstance(arg, ast.IfExp):
|
||||
for branch in (arg.body, arg.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
elif isinstance(node, ast.Assign):
|
||||
targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"]
|
||||
if not targets or not isinstance(node.value, ast.IfExp):
|
||||
continue
|
||||
for branch in (node.value.body, node.value.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]:
|
||||
"""Return every kind matched by ``disconnect_rel``'s ``kind == "…"`` branches."""
|
||||
fn = _find_function(tree, "disconnect_rel")
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(fn):
|
||||
if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq):
|
||||
left = node.left
|
||||
right = node.comparators[0]
|
||||
if isinstance(left, ast.Name) and left.id == "kind":
|
||||
if isinstance(right, ast.Constant) and isinstance(right.value, str):
|
||||
kinds.add(right.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None:
|
||||
tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8"))
|
||||
core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8"))
|
||||
|
||||
emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by(
|
||||
_find_method(tool_tree, "Connection", "find_rels_for_element")
|
||||
)
|
||||
handled = _kind_branches_in_disconnect_rel(core_tree)
|
||||
|
||||
assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed"
|
||||
|
||||
missing = emitted - handled
|
||||
assert not missing, (
|
||||
f"core.connection.disconnect_rel is missing branches for kinds {missing}. "
|
||||
f"Every kind returned by Connection.find_rels / find_rels_for_element "
|
||||
f"must have a matching if/elif branch in the dispatch."
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user