mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
164 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 | |||
| 671217d494 | |||
| dcebf23af8 | |||
| bb8681a954 | |||
| c7d5d6c498 | |||
| 4d22a3fdb9 | |||
| a3593ed58b | |||
| b0eb55cc38 | |||
| f6590d8be2 | |||
| 3136c74c2f | |||
| 347a3c80bb | |||
| 92aa890add | |||
| 05c9df74f9 | |||
| 0c993d3292 | |||
| 32a601d057 | |||
| d1d1e1d4a2 | |||
| a7738eeb64 | |||
| a751fb956d | |||
| 251157f8d4 | |||
| a213a9b848 | |||
| b76bc1c1f8 | |||
| 4d24cef0c9 | |||
| 6c9cfccc43 | |||
| 0a0a5b9f04 | |||
| 3a0abbab95 | |||
| 0d703039a6 | |||
| f33df52c1b | |||
| 15a6375ea3 | |||
| 82465a64a5 | |||
| 6bde619fe6 | |||
| ba5321fdfa | |||
| 49ddc97918 | |||
| 39bcd9db63 | |||
| ab11ac5338 | |||
| 9346f45bba | |||
| b22687891b | |||
| 784f0b1fe2 | |||
| e0ceda6856 | |||
| 17951427fe | |||
| 734f4df84e | |||
| bfa2d789f1 | |||
| 192bf00d31 | |||
| ad672d0edb | |||
| 0df1f0cf49 | |||
| d7dd8ecf57 | |||
| becbcfdfe7 | |||
| 5b79cefee2 | |||
| 3346a59284 | |||
| 0bf8e9283f | |||
| db7591a867 | |||
| 7b9af9f533 | |||
| 704a2d36be | |||
| 516696cd73 | |||
| 93c6350e0f | |||
| a139adaa2c | |||
| a4a806147d | |||
| 4962e3256d | |||
| 06d99feeea | |||
| f584a50fbb | |||
| 24a241addc | |||
| 365be8fb52 | |||
| eacff93945 | |||
| 9d956f18b7 | |||
| 8faf9ff43d | |||
| 87bca20df7 | |||
| a30546f1f2 | |||
| fbe6fe5384 | |||
| bd264f1d85 | |||
| 674ed36e41 | |||
| 1f2b20fd86 | |||
| 94fab271cd | |||
| 8583d0963f | |||
| 77a2284f8a | |||
| 4520a72152 | |||
| 25651a1507 | |||
| 94faaa3160 | |||
| 0e922074b9 | |||
| 90ea256cc3 | |||
| 387bd51b4a | |||
| de4c394b50 | |||
| ab64b652ff | |||
| 99bb1e30ad | |||
| 1707c36bd8 | |||
| 44f5ee028f | |||
| ed7b2fc233 | |||
| 2c18155d98 | |||
| 1e8c0b86a0 | |||
| ac11044261 | |||
| ab9152e32d | |||
| 18dc7abb06 | |||
| b7549f2476 | |||
| ba6cfe9c24 | |||
| b154cadf3b | |||
| 1ba9341201 | |||
| f0aec7b38e | |||
| 4cf34b69d2 | |||
| 44723e83b6 | |||
| 2272c35e9b | |||
| de29d12b00 | |||
| 9440bafc32 | |||
| 0d3543fa31 | |||
| e764559133 |
@@ -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
|
||||
@@ -258,10 +258,14 @@ if(WITH_ROCKSDB)
|
||||
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
|
||||
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
|
||||
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
|
||||
# So rocksdb supported only as a static library.
|
||||
# See https://github.com/facebook/rocksdb/issues/981.
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
if(TARGET RocksDB::rocksdb)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
elseif(TARGET RocksDB::rocksdb-shared)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb-shared)
|
||||
else()
|
||||
message(FATAL_ERROR "RocksDB found but neither RocksDB::rocksdb nor RocksDB::rocksdb-shared target exists")
|
||||
endif()
|
||||
|
||||
if(WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
|
||||
@@ -88,7 +88,15 @@ if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
|
||||
mark_as_advanced(HDF5_DIR)
|
||||
if(HDF5_DIR)
|
||||
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
if(TARGET hdf5_cpp-static)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
elseif(TARGET hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-shared)
|
||||
elseif(TARGET hdf5::hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5::hdf5_cpp-shared)
|
||||
else()
|
||||
find_package(HDF5 REQUIRED COMPONENTS CXX)
|
||||
endif()
|
||||
else()
|
||||
# If it failed, still try to find as a module.
|
||||
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
################################################################################
|
||||
# #
|
||||
# This file is part of IfcOpenShell. #
|
||||
# #
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the Lesser GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3.0 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# IfcOpenShell is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# Lesser GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the Lesser GNU General Public License #
|
||||
# along with this program. If not, see <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
|
||||
|
||||
@@ -29,18 +29,6 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from . import handler, operator, parametric_lifecycle, prop, ui
|
||||
|
||||
|
||||
def _parametric_gizmo_preference_classes() -> list[type]:
|
||||
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
|
||||
``classes`` list below. ``import bonsai.tool`` is kept local to surface
|
||||
the load-order constraint: it relies on ``from . import handler, …``
|
||||
above having primed the
|
||||
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
|
||||
import bonsai.tool as tool
|
||||
|
||||
return tool.Parametric.iter_gizmo_preference_classes(ui)
|
||||
|
||||
|
||||
try:
|
||||
from bonsai.translations import translations_dict
|
||||
except ImportError:
|
||||
@@ -102,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,
|
||||
@@ -171,10 +160,6 @@ classes = [
|
||||
ui.BIM_UL_tab_visibilities,
|
||||
ui.BIM_UL_panel_visibilities,
|
||||
ui.DocPreferences,
|
||||
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
|
||||
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
|
||||
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
|
||||
*_parametric_gizmo_preference_classes(),
|
||||
ui.GizmoPreferences,
|
||||
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
|
||||
# Tabs panel
|
||||
|
||||
@@ -41,14 +41,23 @@ from bonsai.bim.decorator_cache import (
|
||||
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
|
||||
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
|
||||
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
||||
from bonsai.bim.module.model.array import (
|
||||
ArrayPreviewDecorator,
|
||||
ArraySelectionHighlightDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
BendPreviewDecorator,
|
||||
BoundingBoxDecorator,
|
||||
DoorSwingReadonlyDecorator,
|
||||
MEPSegmentExtendPreviewDecorator,
|
||||
MEPSystemPathDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
WallSystemPathDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -116,19 +125,13 @@ def active_object_callback():
|
||||
|
||||
|
||||
def update_bim_tool_props():
|
||||
"""update BIM Tools props (such as extrusion_depth, length and x_angle) when active object changes"""
|
||||
obj = bpy.context.active_object
|
||||
|
||||
# bunch of checks to see if we're in a valid state
|
||||
if not obj:
|
||||
return
|
||||
mode = bpy.context.mode
|
||||
current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
"""Selection-driven BIM Tool sync: re-target user-intent enums
|
||||
(ifc_class, relating_type_id) AND refresh header values
|
||||
(extrusion_depth, length, x_angle) for the new active object."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
aprops = tool.Drawing.get_annotation_props()
|
||||
@@ -178,6 +181,48 @@ def update_bim_tool_props():
|
||||
if is_annotation_tool:
|
||||
return
|
||||
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def refresh_bim_tool_headers():
|
||||
"""Push the active IFC entity's current header float values
|
||||
(extrusion_depth, length, x_angle) into ``BIMModelProperties``.
|
||||
Enum-safe: never writes user-intent enum slots, which are owned by
|
||||
the selection callback."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
if current_tool.idname not in tool.Blender.get_property_header_tools():
|
||||
return
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def _resolve_bim_tool_context():
|
||||
"""Return ``(obj, current_tool, element)`` when an active BIM workspace
|
||||
tool sees a resolvable IFC element; ``None`` otherwise. Defensive
|
||||
against stripped operator contexts — a missing ``active_object`` /
|
||||
``mode`` / ``workspace`` short-circuits to ``None`` instead of raising."""
|
||||
obj = tool.Blender.get_active_object()
|
||||
if not obj:
|
||||
return None
|
||||
mode = getattr(bpy.context, "mode", None)
|
||||
workspace = getattr(bpy.context, "workspace", None)
|
||||
if mode is None or workspace is None:
|
||||
return None
|
||||
current_tool = workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return None
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return None
|
||||
return obj, current_tool, element
|
||||
|
||||
|
||||
def _read_headers_into_props(obj, element):
|
||||
"""Populate ``BIMModelProperties`` header values from the active
|
||||
object's IFC extrusion. Enum-safe: writes only header floats, never
|
||||
user-intent enum slots, so it is safe to call on the post-commit hook."""
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
return
|
||||
@@ -195,6 +240,7 @@ def update_bim_tool_props():
|
||||
if not AuthoringData.is_loaded:
|
||||
AuthoringData.load()
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
x_angle = get_x_angle(extrusion)
|
||||
axis = tool.Model.get_wall_axis(obj)["reference"]
|
||||
@@ -393,8 +439,8 @@ def subscribe_to_viewport_shading_changes():
|
||||
|
||||
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
|
||||
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
|
||||
and previews discarded so saved preview state never resurfaces on reopen."""
|
||||
settings, scene-bound caches, load-transient parametric state, and the
|
||||
multi-instance lock probe."""
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -405,8 +451,7 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
ifcopenshell.api.owner.settings.get_application = get_application
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
tool.Parametric.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
tool.Parametric.on_load_post(scene)
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
@@ -470,7 +515,15 @@ def _install_viewport_overlays() -> None:
|
||||
NestDecorator.uninstall()
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
MEPSystemPathDecorator.uninstall()
|
||||
WallSystemPathDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
BendPreviewDecorator.uninstall()
|
||||
MEPSegmentExtendPreviewDecorator.uninstall()
|
||||
WallGizmoPreviewDecorator.uninstall()
|
||||
DoorSwingReadonlyDecorator.uninstall()
|
||||
ArrayPreviewDecorator.uninstall()
|
||||
ArraySelectionHighlightDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
try:
|
||||
if georeference_props.should_visualise:
|
||||
@@ -483,12 +536,35 @@ 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.
|
||||
# wall_fillet.is_active, so installation has no cost when no preview
|
||||
# is open. No corresponding addon-preference toggle.
|
||||
WallFilletPreviewDecorator.install(bpy.context)
|
||||
# Always-installed siblings of WallFilletPreviewDecorator: each
|
||||
# self-polls on its own scene.BIMPreviewProperties subgroup or on
|
||||
# selection + hover gizmo state — zero cost when nothing is active.
|
||||
BendPreviewDecorator.install(bpy.context)
|
||||
MEPSegmentExtendPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw_lines() self-polls on selection + hover state
|
||||
# for join / extend-to-wall / cursor-extend / cursor-split previews.
|
||||
# Free when no preview-eligible state is active.
|
||||
WallGizmoPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on active object + IfcDoor +
|
||||
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
|
||||
# nothing eligible is selected.
|
||||
DoorSwingReadonlyDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on the active object's array
|
||||
# family membership, so installation has no cost when no array
|
||||
# element is selected.
|
||||
ArraySelectionHighlightDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on props.is_editing — only
|
||||
# paints during an active array edit lifecycle.
|
||||
ArrayPreviewDecorator.install(bpy.context)
|
||||
finally:
|
||||
install_decorator_cache_handlers()
|
||||
|
||||
|
||||
@@ -552,7 +552,7 @@ class IfcStore:
|
||||
BrickStore.end_transaction()
|
||||
IfcStore.end_transaction(operator)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Parametric.refresh_post_commit()
|
||||
tool.Parametric.refresh_post_commit(operator)
|
||||
|
||||
if method == "MODAL":
|
||||
cls.modal_in_progress = False
|
||||
@@ -566,6 +566,19 @@ class IfcStore:
|
||||
result = getattr(operator, "_modal")(context, event)
|
||||
except:
|
||||
bonsai.last_error = traceback.format_exc()
|
||||
# An operator that mutated IFC then raised leaves the IFC graph captured
|
||||
# by the transaction but the Blender side stale. Blender does not push an
|
||||
# undo step for a raised operator (mirror of the CANCELLED-modal gap
|
||||
# handled below), so we push one here so Ctrl+Z actually rewinds the
|
||||
# partial mutation, then surface the recovery path to the user.
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file and ifc_file.transaction and ifc_file.transaction.operations:
|
||||
bpy.ops.ed.undo_push(message=f"Recover {operator.bl_idname}")
|
||||
operator.report(
|
||||
{"WARNING"},
|
||||
"Operation partially completed (IFC changed, Blender state may be stale). "
|
||||
"Press Ctrl+Z to restore the previous state.",
|
||||
)
|
||||
# Try to ensure undo will work since Blender undo does work in case of errors.
|
||||
# As error come unexpectedly, it's important that user might have a chance to save the file
|
||||
# before they got the error and not to lose the work they've done.
|
||||
|
||||
@@ -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] = {}
|
||||
@@ -1219,8 +1220,18 @@ class IfcImporter:
|
||||
if element not in elements_to_import:
|
||||
continue
|
||||
for i in range(len(data)):
|
||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
tool.Array.set_children_lock_state(element, i, True)
|
||||
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")
|
||||
@@ -141,6 +141,7 @@ classes = (
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
gizmos.GizmoArc,
|
||||
gizmos.GizmoLinkToggle,
|
||||
gizmos.GizmoFillet,
|
||||
gizmos.GizmoWallCornerIcon,
|
||||
gizmos.GizmoWallTeeIcon,
|
||||
@@ -153,6 +154,7 @@ classes = (
|
||||
gizmos.GizmoArrayParent,
|
||||
gizmos.GizmoArrayAll,
|
||||
gizmos.GizmoArrayLayerIndicator,
|
||||
gizmos.GizmoCountLabel,
|
||||
gizmos.GizmoMerge,
|
||||
gizmos.GizmoSplit,
|
||||
gizmos.GizmoUnjoin,
|
||||
|
||||
@@ -35,6 +35,7 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`)
|
||||
"CoordinateSpace",
|
||||
"ModalState",
|
||||
"DimensionGizmoConfig",
|
||||
"SwingArcConfig",
|
||||
"ViewDirection",
|
||||
"GizmoModalContext",
|
||||
"get_modal_context",
|
||||
@@ -81,7 +82,7 @@ import math
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Literal, Protocol, runtime_checkable
|
||||
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
@@ -104,16 +105,6 @@ from mathutils.kdtree import KDTree
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
|
||||
|
||||
# Backward-compat re-exports — these mixins moved to bim.parametric_lifecycle
|
||||
# in the gizmos.py framework refactor. PR4 callers (CycleDoorType / CycleWindowType
|
||||
# / CycleStairType) still spell gizmo.CycleTypeMixin; the re-export keeps the
|
||||
# old access path alive until PR4 rewrites the import. PR5 cleanup drops these.
|
||||
from bonsai.bim.parametric_lifecycle import ( # noqa: F401, E402
|
||||
CycleTypeMixin,
|
||||
PickTypeMixin,
|
||||
TypeAccessorBase,
|
||||
)
|
||||
|
||||
SNAP_POINT_SIZE = 10.0
|
||||
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
|
||||
SNAP_MAX_RADIUS = 50.0
|
||||
@@ -143,6 +134,16 @@ DOOR_SWING_ANGLE_MAX = 90.0
|
||||
# Default scale factor for billboarded icons (Blender-unit visual size).
|
||||
DEFAULT_BILLBOARD_SCALE = 0.5
|
||||
|
||||
# Shared gizmo color constants. Re-exported as class attributes on
|
||||
# BaseParametricGizmoGroup so callers can use either ``self.COLOR_GREEN``
|
||||
# from inside a gizmo group or the module-level constant from a class body
|
||||
# (e.g. IconSlot declarations) without a forward-reference issue. Match
|
||||
# Blender's axis convention: X=red, Y=green, Z=blue.
|
||||
COLOR_RED = (1.0, 0.2, 0.2)
|
||||
COLOR_GREEN = (0.1, 0.8, 0.1)
|
||||
COLOR_BLUE = (0.3, 0.3, 1.0)
|
||||
COLOR_NEUTRAL = (1.0, 1.0, 1.0)
|
||||
|
||||
PRECISION_MODE_MULTIPLIER = 0.1
|
||||
|
||||
RAY_CAST_DISTANCE = 1000
|
||||
@@ -158,6 +159,38 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
|
||||
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
|
||||
|
||||
|
||||
def _is_transform_modal_active(context) -> bool:
|
||||
"""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:
|
||||
"""Set ``hide = True`` on every gizmo in ``group`` whose own ``is_modal``
|
||||
is False. Used by parametric ``draw_prepare`` to suppress visible
|
||||
re-positioning while a transform modal is dragging ``matrix_world``."""
|
||||
for gz in group.gizmos:
|
||||
if not getattr(gz, "is_modal", False):
|
||||
gz.hide = True
|
||||
|
||||
|
||||
def apply_transform_modal_draw_gate(group, context) -> bool:
|
||||
"""Combined gate for ``draw_prepare`` overrides: hide non-modal gizmos and
|
||||
return ``True`` when a Blender transform modal is dragging matrix_world.
|
||||
|
||||
Returns ``False`` when no transform modal is active so callers can fall
|
||||
through to their normal positioning logic. ``True`` means the caller must
|
||||
early-return without touching matrix_basis — the hidden gizmos will be
|
||||
re-shown on the next idle frame once the modal exits."""
|
||||
if not _is_transform_modal_active(context):
|
||||
return False
|
||||
_hide_all_non_modal_gizmos(group)
|
||||
return True
|
||||
|
||||
|
||||
class GizmoColor(Enum):
|
||||
"""Color identifiers for dimension gizmos.
|
||||
|
||||
@@ -1291,6 +1324,38 @@ class IconActionConfig:
|
||||
visibility_condition: Callable[[Any], bool] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwingArcConfig:
|
||||
"""Declarative config for one swing-arc panel — a pair of ``GizmoArc``
|
||||
instances representing a single hinged panel's two possible open sides.
|
||||
|
||||
Each entry produces two gizmos at setup time:
|
||||
- ``self.gizmo_swing_arc_<name>``: main arc on the active swing side
|
||||
- ``self.gizmo_swing_arc_<name>_flip``: Y-mirror of the main, on the
|
||||
opposite side of the hinge line
|
||||
|
||||
Both gizmos hide together when ``visibility_condition(props)`` is False.
|
||||
When visible, each arc's ``matrix_basis`` is:
|
||||
|
||||
Translation(hinge_x(props), hinge_y(props), 0)
|
||||
@ Scale(panel_width(props), 4)
|
||||
@ (Scale(-1, X) if x_mirror(props) else Identity)
|
||||
@ (Scale(-1, Y) if this is the flip arc else Identity)
|
||||
|
||||
The arc geometry (``GizmoArc.tris``) is a unit quarter-arc sweeping
|
||||
counterclockwise from +X to +Y with its hinge at the origin, so the
|
||||
transforms above translate the hinge into world position, scale to
|
||||
panel size, and mirror across the hinge line as needed.
|
||||
"""
|
||||
|
||||
name: str
|
||||
visibility_condition: Callable[[Any], bool]
|
||||
hinge_x: Callable[[Any], float]
|
||||
hinge_y: Callable[[Any], float]
|
||||
panel_width: Callable[[Any], float]
|
||||
x_mirror: Callable[[Any], bool]
|
||||
|
||||
|
||||
class SnapManager:
|
||||
"""Manages snap point visualization and mesh snapping with caching."""
|
||||
|
||||
@@ -1458,24 +1523,11 @@ class SnapManager:
|
||||
nearby_objects = []
|
||||
|
||||
for obj in mesh_objects:
|
||||
bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
if not bbox_corners:
|
||||
if not obj.bound_box:
|
||||
continue
|
||||
|
||||
bbox_min = Vector(
|
||||
(
|
||||
min(c.x for c in bbox_corners),
|
||||
min(c.y for c in bbox_corners),
|
||||
min(c.z for c in bbox_corners),
|
||||
)
|
||||
)
|
||||
bbox_max = Vector(
|
||||
(
|
||||
max(c.x for c in bbox_corners),
|
||||
max(c.y for c in bbox_corners),
|
||||
max(c.z for c in bbox_corners),
|
||||
)
|
||||
)
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
bbox_min = bbox["min_point"]
|
||||
bbox_max = bbox["max_point"]
|
||||
|
||||
closest = Vector(
|
||||
(
|
||||
@@ -1674,6 +1726,33 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA
|
||||
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
|
||||
|
||||
|
||||
def billboarded_along_axis(
|
||||
world_pos: Vector,
|
||||
billboard_rot: Matrix,
|
||||
axis_world: Vector,
|
||||
scale: float = DEFAULT_BILLBOARD_SCALE,
|
||||
) -> Matrix:
|
||||
"""Composed matrix_basis like ``billboarded_at`` but with local +X
|
||||
rotated about the camera-forward axis to align with ``axis_world``
|
||||
projected onto the screen plane.
|
||||
|
||||
The gizmo still faces the camera (local +Z stays along camera-forward),
|
||||
only its in-plane orientation changes. Falls back to plain
|
||||
``billboarded_at`` when the axis is near-parallel to the view direction
|
||||
(no usable screen projection)."""
|
||||
camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0))
|
||||
projected = axis_world - camera_forward * axis_world.dot(camera_forward)
|
||||
if projected.length < 1e-4:
|
||||
return billboarded_at(world_pos, billboard_rot, scale)
|
||||
projected.normalize()
|
||||
y_axis = camera_forward.cross(projected).normalized()
|
||||
rot = Matrix.Identity(4)
|
||||
rot[0][:3] = (projected.x, y_axis.x, camera_forward.x)
|
||||
rot[1][:3] = (projected.y, y_axis.y, camera_forward.y)
|
||||
rot[2][:3] = (projected.z, y_axis.z, camera_forward.z)
|
||||
return Matrix.Translation(world_pos) @ rot @ Matrix.Scale(scale, 4)
|
||||
|
||||
|
||||
def get_screen_up(billboard_rot: Matrix) -> Vector:
|
||||
"""Camera's screen-up direction in world space — local +Y of the billboard
|
||||
rotation. Use to lift a gizmo above an anchor in a way that stays
|
||||
@@ -1682,6 +1761,33 @@ def get_screen_up(billboard_rot: Matrix) -> Vector:
|
||||
return billboard_rot @ Vector((0.0, 1.0, 0.0))
|
||||
|
||||
|
||||
# Screen-up distance lifted off floor-plane gizmo anchors in plan view. Matches
|
||||
# the inter-icon stack spacing used by wall-corner stacks so single icons and
|
||||
# stack bases sit at consistent screen-up positions when multiple groups render
|
||||
# around the same wall endpoint.
|
||||
DEFAULT_TOP_DOWN_CLEARANCE = 0.4
|
||||
|
||||
|
||||
def top_down_clearance(
|
||||
context: bpy.types.Context,
|
||||
billboard_rot: Matrix,
|
||||
distance: float = DEFAULT_TOP_DOWN_CLEARANCE,
|
||||
) -> Vector:
|
||||
"""Screen-up offset that keeps a floor-plane gizmo anchor visible in plan view.
|
||||
|
||||
In a top-down view the world-Z axis projects to ~zero on screen, so any
|
||||
icon anchored on the floor (wall endpoints, corners, connection points,
|
||||
the projected 3D cursor) sits directly on the click target it represents.
|
||||
Adding this offset before ``billboarded_at`` shifts the icon along the
|
||||
camera's up axis without changing the operator's world-space target.
|
||||
|
||||
Returns a zero vector outside the top-down cone so callers can apply it
|
||||
unconditionally."""
|
||||
if not tool.Blender.is_view_top_down(context):
|
||||
return Vector((0.0, 0.0, 0.0))
|
||||
return get_screen_up(billboard_rot) * distance
|
||||
|
||||
|
||||
# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the
|
||||
# element origin.
|
||||
EXTEND_FLIP_EPSILON = 1e-4
|
||||
@@ -3208,11 +3314,124 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Static quarter-arc glyph for swing visualisation.
|
||||
|
||||
Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to
|
||||
``matrix_basis``."""
|
||||
``matrix_basis``. ``outline_alpha = 0.0`` suppresses the inherited 8-pass
|
||||
dark halo: an open curve has no enclosed silhouette for the dilation to
|
||||
ring, so the offset passes read as ghost arcs rather than a uniform
|
||||
outline. The arc's own cross-section thickness keeps it legible without
|
||||
the halo."""
|
||||
|
||||
bl_idname = "VIEW3D_GT_arc"
|
||||
__slots__ = ("custom_shape",)
|
||||
tris = ARC_TRIS_DEFAULT
|
||||
outline_alpha = 0.0
|
||||
|
||||
|
||||
def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]:
|
||||
"""Two filled dots joined by a horizontal connector. ``broken=False``
|
||||
draws a single continuous bar between the dots' inner edges;
|
||||
``broken=True`` shears the two halves vertically — the left dot AND
|
||||
its stub slip down as a unit, the right dot AND its stub slip up,
|
||||
with a horizontal gap at the centre.
|
||||
|
||||
Each half moves as a cohesive piece so the stub stays attached to its
|
||||
dot at the same y, reading as a snapped link whose two halves slid
|
||||
apart rather than as bent stubs jutting out of stationary dots."""
|
||||
dot_cx = 0.30
|
||||
dot_r = 0.10
|
||||
bar_half_thickness = 0.04
|
||||
# Inner edge of each dot — the intact bar joins the dot edges, not the
|
||||
# centres, so the dot + bar reads as one continuous shape.
|
||||
bar_inner_x = dot_cx - dot_r
|
||||
segments = 12
|
||||
# Vertical shear applied to each half when broken. Zero in the intact
|
||||
# form keeps both halves on the centerline.
|
||||
half_offset_y = 0.08 if broken else 0.0
|
||||
|
||||
tris: list[tuple[float, float, float]] = []
|
||||
for sign in (-1, 1):
|
||||
cx = sign * dot_cx
|
||||
cy = sign * half_offset_y
|
||||
for i in range(segments):
|
||||
a1 = (2.0 * math.pi) * (i / segments)
|
||||
a2 = (2.0 * math.pi) * ((i + 1) / segments)
|
||||
p1 = (cx + dot_r * math.cos(a1), cy + dot_r * math.sin(a1))
|
||||
p2 = (cx + dot_r * math.cos(a2), cy + dot_r * math.sin(a2))
|
||||
tris.append((cx, cy, 0.0))
|
||||
tris.append((p1[0], p1[1], 0.0))
|
||||
tris.append((p2[0], p2[1], 0.0))
|
||||
|
||||
if broken:
|
||||
# Stubs reach inward into the dot's interior so they read as rooted
|
||||
# in the dot rather than floating off its edge after the slip.
|
||||
stub_outer_x = 0.27
|
||||
stub_inner_x = 0.06
|
||||
tris.extend(
|
||||
rect_tris(
|
||||
-stub_outer_x,
|
||||
-half_offset_y - bar_half_thickness,
|
||||
-stub_inner_x,
|
||||
-half_offset_y + bar_half_thickness,
|
||||
)
|
||||
)
|
||||
tris.extend(
|
||||
rect_tris(
|
||||
stub_inner_x,
|
||||
half_offset_y - bar_half_thickness,
|
||||
stub_outer_x,
|
||||
half_offset_y + bar_half_thickness,
|
||||
)
|
||||
)
|
||||
else:
|
||||
tris.extend(rect_tris(-bar_inner_x, -bar_half_thickness, bar_inner_x, bar_half_thickness))
|
||||
|
||||
return tuple(tris)
|
||||
|
||||
|
||||
LINK_TRIS_INTACT = _link_toggle_icon_tris(broken=False)
|
||||
LINK_TRIS_BROKEN = _link_toggle_icon_tris(broken=True)
|
||||
|
||||
|
||||
class GizmoLinkToggle(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Two-state link glyph: default reads as a connected link (two dots +
|
||||
intact connector); hover swaps to a broken link (same dots + severed
|
||||
connector) to signal that a click will sever the underlying connection.
|
||||
|
||||
Single-click gizmo — the target operator is bound via
|
||||
``target_set_operator`` by the owning group. The hover swap is purely
|
||||
visual; the click target is the same in both states. The glyph is
|
||||
feature-agnostic — any path / link / pair-of-connected-items context can
|
||||
reuse it for a sever-this-connection affordance."""
|
||||
|
||||
bl_idname = "VIEW3D_GT_link_toggle"
|
||||
__slots__ = ("custom_shape",)
|
||||
# Bbox source for the hit shape. The broken form's vertically-sheared
|
||||
# halves give it the larger bbox of the two states, so using it as the
|
||||
# hit-shape source guarantees the clickable area covers either form —
|
||||
# the cursor doesn't lose hover at the offset dots' outer edges.
|
||||
tris = LINK_TRIS_BROKEN
|
||||
|
||||
# Per-class batch cache: one entry per highlight state. The mixin parent
|
||||
# caches one batch per class via ``_get_static_tris_batch``; the per-state
|
||||
# swap needs a second batch, so this class keeps its own cache.
|
||||
_batch_cache: ClassVar[dict[bool, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
broken = bool(self.is_highlight)
|
||||
batch = type(self)._batch_cache.get(broken)
|
||||
if batch is None:
|
||||
tris = LINK_TRIS_BROKEN if broken else LINK_TRIS_INTACT
|
||||
batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
|
||||
type(self)._batch_cache[broken] = batch
|
||||
# Icon body forced fully opaque so the dark outline behind doesn't
|
||||
# bleed through and grey out the glyph.
|
||||
color = (*self.color_highlight, 1.0) if broken else (*self.color, 1.0)
|
||||
draw_tris_with_outline(
|
||||
batch,
|
||||
self.matrix_basis @ self.matrix_offset,
|
||||
color,
|
||||
self.outline_width,
|
||||
self.outline_alpha,
|
||||
)
|
||||
|
||||
|
||||
def _fillet_icon_tris() -> tuple[tuple[float, float, float], ...]:
|
||||
@@ -3271,8 +3490,9 @@ class GizmoFillet(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
bl_idname = "VIEW3D_GT_fillet"
|
||||
__slots__ = ("custom_shape",)
|
||||
tris = FILLET_TRIS_DEFAULT
|
||||
# Stacked at ICON_STACK_OFFSET_Y above join in GizmoWallJoinIntersection;
|
||||
# full-bbox hit overlaps the sibling icons' bboxes and steals their clicks.
|
||||
# Stacked at ICON_STACK_OFFSET_Y above the join icon in the wall-join
|
||||
# gizmo group; full-bbox hit overlaps the sibling icons' bboxes and
|
||||
# steals their clicks.
|
||||
hit_uses_bbox = False
|
||||
|
||||
|
||||
@@ -3663,7 +3883,7 @@ class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
parent_element = tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
return
|
||||
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
|
||||
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
|
||||
|
||||
draw_array_layer_children_bbox(context, parent_element, layer_index)
|
||||
|
||||
@@ -3748,11 +3968,49 @@ class GizmoArrayLayerIndicator(bpy.types.Gizmo):
|
||||
parent_element = tool.Ifc.get_entity(obj)
|
||||
if parent_element is None:
|
||||
return
|
||||
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
|
||||
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
|
||||
|
||||
draw_array_layer_children_bbox(context, parent_element, self._layer_index)
|
||||
|
||||
|
||||
class GizmoCountLabel(bpy.types.Gizmo):
|
||||
"""``xN`` text label rendered from 7-segment digit triangles.
|
||||
|
||||
Mirrors a caller-supplied integer into a live count badge. No icon
|
||||
glyph; the gizmo is the number alone."""
|
||||
|
||||
bl_idname = "BIM_GT_count_label"
|
||||
|
||||
__slots__ = ("custom_shape", "_count", "_built_count", "_outlined_batch")
|
||||
|
||||
def setup(self) -> None:
|
||||
self._count = 0
|
||||
self._built_count = -1
|
||||
tris = _count_label_tris(self._count, 0.0, 0.0)
|
||||
self.custom_shape = self.new_custom_shape("TRIS", tris)
|
||||
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
|
||||
self._built_count = 0
|
||||
|
||||
def set_count(self, count: int) -> None:
|
||||
self._count = int(count)
|
||||
|
||||
def _ensure_shape(self) -> None:
|
||||
if self._built_count != self._count:
|
||||
tris = _count_label_tris(self._count, 0.0, 0.0)
|
||||
self.custom_shape = self.new_custom_shape("TRIS", tris)
|
||||
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
|
||||
self._built_count = self._count
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
self._ensure_shape()
|
||||
color = (*self.color_highlight, 1.0) if self.is_highlight else (*self.color, 1.0)
|
||||
draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color)
|
||||
|
||||
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
|
||||
self._ensure_shape()
|
||||
self.draw_custom_shape(self.custom_shape, select_id=select_id)
|
||||
|
||||
|
||||
class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Two arrows pointing inward toward each other — conveys joining/merging elements."""
|
||||
|
||||
@@ -4049,7 +4307,8 @@ def _generate_menu_tris() -> tuple[tuple[float, float, float], ...]:
|
||||
class GizmoMenu(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Hamburger-stack menu icon — 'open a picker to choose from many options'.
|
||||
|
||||
For enums with 5+ values; use ``GizmoCycle`` for 2-4."""
|
||||
For enums with 3+ values; use ``GizmoCycle`` for exactly 2 (where the
|
||||
advance-one-per-click semantic stays predictable)."""
|
||||
|
||||
bl_idname = "VIEW3D_GT_menu"
|
||||
|
||||
@@ -4591,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
|
||||
|
||||
@@ -4633,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)
|
||||
|
||||
@@ -4661,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)
|
||||
@@ -4803,6 +5076,8 @@ class BillboardingGizmoGroupMixin:
|
||||
self.position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
if apply_transform_modal_draw_gate(self, context):
|
||||
return
|
||||
self.position_gizmos(context)
|
||||
|
||||
def setup_icon_gizmo(
|
||||
@@ -4816,12 +5091,130 @@ class BillboardingGizmoGroupMixin:
|
||||
"""Convenience wrapper over `setup_icon_gizmo` for subclasses."""
|
||||
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
|
||||
|
||||
def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
||||
"""Standard (default, highlight) color pair for active-state gizmos.
|
||||
Pulls from the addon preferences — same source consumed by every
|
||||
Bonsai decorator. Hover-class gizmos that should not pull focus
|
||||
should use ``get_unselected_decoration_colors`` instead."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
|
||||
|
||||
def get_unselected_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
||||
"""Lower-priority (unselected default, highlight) pair for gizmos
|
||||
that surface on already-selected geometry and shouldn't compete
|
||||
visually with the selection outline (e.g. array-child navigation)."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
return prefs.decorator_color_unselected[:3], prefs.decorator_color_selected[:3]
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IconSlot:
|
||||
"""One slot in a parametric edit gizmo's icon toolbar row.
|
||||
|
||||
The slot's X coordinate is COMPUTED from its index in ``feature_slots`` —
|
||||
never set explicitly. Adding an icon is a one-line append; the layout
|
||||
manager resolves the X. Hidden slots STILL CONSUME their X position so
|
||||
toggling a visibility preference doesn't shift the row.
|
||||
|
||||
Fields:
|
||||
|
||||
- ``gizmo_idname`` — for single-icon slots, the full Blender gizmo
|
||||
idname. For multi-variant slots, EITHER a string PREFIX that auto-
|
||||
suffixes ``_<variant>`` per member (the common case — e.g.
|
||||
``"VIEW3D_GT_lock"`` + variants ``("open", "closed")`` becomes
|
||||
``VIEW3D_GT_lock_open`` / ``VIEW3D_GT_lock_closed``) OR a tuple of
|
||||
explicit idnames matching the variant count when the variants
|
||||
don't share a prefix. Attributes created on the gizmo group are
|
||||
``self.<name>_gizmo`` for single slots, ``self.<name>_<variant>_gizmo``
|
||||
for each variant in multi-variant slots.
|
||||
- ``variants`` — variant suffixes, e.g. ``("open", "closed")`` for a
|
||||
lock pair, ``("exterior", "center", "interior")`` for a baseline
|
||||
cycle. Empty tuple = single icon.
|
||||
- ``color`` — RGB tuple. ``None`` falls back to the gizmo group's default
|
||||
decoration color. Use the group's ``COLOR_RED`` / ``COLOR_GREEN`` /
|
||||
``COLOR_BLUE`` literals for state-coded icons.
|
||||
- ``extra_gap_before`` — extra spacing past the default uniform gap, in
|
||||
meters. Use sparingly — e.g. to visually separate a destructive
|
||||
action (trash) from the routine edit controls.
|
||||
- ``operator_props`` — tuple of (key, value) pairs forwarded to
|
||||
``target_set_operator``'s return value (e.g. ``increment=1`` for a
|
||||
+/- adjuster, ``property_name="..."`` for a generic toggle).
|
||||
- ``placeholder`` — when ``True``, the slot reserves an X position in
|
||||
the row but no auto-managed gizmo is created. Subclasses look the X
|
||||
up via ``_slot_x_positions()[name]`` to place their own dynamically-
|
||||
built gizmos (e.g. a live count label). ``gizmo_idname`` / ``operator``
|
||||
are unused for placeholders."""
|
||||
|
||||
name: str
|
||||
gizmo_idname: str | tuple[str, ...] = ""
|
||||
operator: str = ""
|
||||
# Matches DEFAULT_BILLBOARD_SCALE — the scale validate/cancel render at,
|
||||
# so slots that don't override land at the same visual size by default.
|
||||
# Helper icons (+/- count adjusters, lock pairs, delete) override with
|
||||
# smaller values (0.20 - 0.35) to signal secondary affordance.
|
||||
scale: float = DEFAULT_BILLBOARD_SCALE
|
||||
color: tuple[float, float, float] | None = None
|
||||
variants: tuple[str, ...] = ()
|
||||
extra_gap_before: float = 0.0
|
||||
operator_props: tuple[tuple[str, Any], ...] = ()
|
||||
placeholder: bool = False
|
||||
# Optional per-frame visibility predicate. Called with the gizmo group
|
||||
# instance as the sole argument; returning False hides this slot's gizmo
|
||||
# while still reserving its X position so the row layout doesn't shift.
|
||||
# Used for idle-row icons whose relevance depends on element state (e.g.
|
||||
# toggle_openings only when the host has openings).
|
||||
visible_when: Optional[Callable[[Any], bool]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate shape at class-definition time so a typo doesn't surface
|
||||
# as a runtime error in the gizmo group's setup() three layers deep.
|
||||
if self.placeholder:
|
||||
return
|
||||
if self.variants:
|
||||
if isinstance(self.gizmo_idname, str):
|
||||
pass # prefix form — idname auto-suffixed per variant
|
||||
elif isinstance(self.gizmo_idname, tuple) and len(self.gizmo_idname) == len(self.variants):
|
||||
pass # explicit-tuple form
|
||||
else:
|
||||
raise TypeError(
|
||||
f"IconSlot({self.name!r}): variants={self.variants} requires gizmo_idname "
|
||||
f"to be either a string prefix (auto-suffixed as <prefix>_<variant>) or a "
|
||||
f"tuple of {len(self.variants)} explicit idnames, got {self.gizmo_idname!r}"
|
||||
)
|
||||
elif not isinstance(self.gizmo_idname, str) or not self.gizmo_idname:
|
||||
raise TypeError(
|
||||
f"IconSlot({self.name!r}): single-icon slot requires gizmo_idname str, "
|
||||
f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant "
|
||||
f"slot, or placeholder=True for a reserved-position slot)"
|
||||
)
|
||||
|
||||
def variant_idnames(self) -> tuple[str, ...]:
|
||||
"""Resolve per-variant gizmo idnames. For prefix form, suffix each
|
||||
variant onto the prefix; for tuple form, return as is. Single-icon
|
||||
slots return a one-element tuple containing the idname."""
|
||||
if not self.variants:
|
||||
assert isinstance(self.gizmo_idname, str)
|
||||
return (self.gizmo_idname,)
|
||||
if isinstance(self.gizmo_idname, str):
|
||||
return tuple(f"{self.gizmo_idname}_{variant}" for variant in self.variants)
|
||||
return self.gizmo_idname
|
||||
|
||||
def gizmo_attrs(self) -> tuple[str, ...]:
|
||||
"""Names of every ``self.*`` attribute this slot writes during setup.
|
||||
Returns one for a single slot, N for an N-variant slot, and an empty
|
||||
tuple for placeholder slots (which reserve X without an auto-gizmo)."""
|
||||
if self.placeholder:
|
||||
return ()
|
||||
if self.variants:
|
||||
return tuple(f"{self.name}_{variant}_gizmo" for variant in self.variants)
|
||||
return (f"{self.name}_gizmo",)
|
||||
|
||||
|
||||
class BaseParametricGizmoGroup:
|
||||
"""Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.).
|
||||
|
||||
@@ -4897,11 +5290,13 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
|
||||
# === Gizmo Colors ===
|
||||
# Match Blender axis convention: X=red, Y=green, Z=blue
|
||||
COLOR_RED = (1.0, 0.2, 0.2)
|
||||
COLOR_GREEN = (0.1, 0.8, 0.1)
|
||||
COLOR_BLUE = (0.3, 0.3, 1.0)
|
||||
COLOR_NEUTRAL = (1.0, 1.0, 1.0)
|
||||
# Aliased to the module-level constants so subclass class bodies can
|
||||
# reference either spelling. Match Blender's axis convention:
|
||||
# X=red, Y=green, Z=blue.
|
||||
COLOR_RED = COLOR_RED
|
||||
COLOR_GREEN = COLOR_GREEN
|
||||
COLOR_BLUE = COLOR_BLUE
|
||||
COLOR_NEUTRAL = COLOR_NEUTRAL
|
||||
|
||||
# === Dimension Gizmo Layout (meters) ===
|
||||
ARROW_SCALE = 0.25 # Scale factor for arrow gizmos
|
||||
@@ -4912,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]
|
||||
@@ -4920,17 +5322,21 @@ class BaseParametricGizmoGroup:
|
||||
ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon
|
||||
ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon
|
||||
ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon
|
||||
# Rightmost local-X used by feature-specific icons (across both idle and
|
||||
# edit states). Subclasses override when they add icons past the cycle
|
||||
# slot at 0.87 — currently wall (rotate at 1.24) and stair (minus at
|
||||
# 1.98). Drives both the ARRAY button position (this class) AND the
|
||||
# array-layer-icons start position (``GizmoArrayEdition`` runtime lookup),
|
||||
# so non-colliding features get a tight layout while wall / stair shift
|
||||
# the array-related slots outward to avoid stomping on the rotate /
|
||||
# tread-lock / +/- icons.
|
||||
FEATURE_ICON_MAX_X: float = 0.87
|
||||
# Gap between the last feature icon and the ARRAY button (or the first
|
||||
# array layer icon in idle state).
|
||||
# Subclasses append to declare feature icons in the edit-mode toolbar row.
|
||||
# The layout manager assigns each slot an X position from its tuple
|
||||
# index — adding a new icon is a one-line append, no hardcoded X
|
||||
# constant, no "remember to bump the right edge" rule. The trailing
|
||||
# ARRAY button is positioned past the last slot automatically.
|
||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = ()
|
||||
# Idle-mode pen-row extras (e.g. wall's toggle_openings). Each slot is
|
||||
# placed past the pen at uniform ``ICON_ARRAY_GAP`` spacing. Hidden during
|
||||
# edit — the validate/cancel row owns the X positions there. Peer gizmo
|
||||
# groups (e.g. ``GizmoArrayEdition``'s per-layer ARRAY icons) query
|
||||
# ``_idle_row_right_edge()`` to position past these without a hardcoded
|
||||
# per-feature table.
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = ()
|
||||
# Gap between adjacent slots past the leading validate/cancel/cycle
|
||||
# triplet, AND between the last slot and the ARRAY button.
|
||||
ICON_ARRAY_GAP: float = 0.37
|
||||
ICON_Z_OFFSET = 0.5 # Height above element for icons
|
||||
ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry
|
||||
@@ -4952,6 +5358,62 @@ class BaseParametricGizmoGroup:
|
||||
super().__init_subclass__(**kwargs)
|
||||
BaseParametricGizmoGroup.REGISTRY.append(cls)
|
||||
|
||||
@classmethod
|
||||
def _slot_x_positions(cls) -> dict[str, float]:
|
||||
"""Map each ``feature_slot`` name to its X coordinate in the row.
|
||||
|
||||
Slots are laid out from the cycle position onward at uniform
|
||||
``ICON_ARRAY_GAP`` spacing, plus any per-slot ``extra_gap_before``.
|
||||
When the cycle slot is unused (no ``cycle_type_operator`` /
|
||||
``pick_type_operator``), the first feature slot collapses into the
|
||||
cycle position so the row stays tight — that's how wall's baseline
|
||||
triplet ends up at X=0.87 without a gap before it. Tuple order is
|
||||
the only thing that controls X; rearranging the tuple rearranges
|
||||
the row."""
|
||||
positions: dict[str, float] = {}
|
||||
has_cycle = bool(cls.cycle_type_operator) or bool(cls.pick_type_operator)
|
||||
next_x = (cls.ICON_CYCLE_X + cls.ICON_ARRAY_GAP) if has_cycle else cls.ICON_CYCLE_X
|
||||
for slot in cls.feature_slots:
|
||||
next_x += slot.extra_gap_before
|
||||
positions[slot.name] = next_x
|
||||
next_x += cls.ICON_ARRAY_GAP
|
||||
return positions
|
||||
|
||||
@classmethod
|
||||
def _feature_row_right_edge(cls) -> float:
|
||||
"""Right edge of the feature icon row, fed to the trailing ARRAY
|
||||
button's X. Computed strictly from slot order + gaps; empty
|
||||
``feature_slots`` collapses to the cycle position."""
|
||||
positions = cls._slot_x_positions()
|
||||
if not positions:
|
||||
return cls.ICON_CYCLE_X
|
||||
return max(positions.values())
|
||||
|
||||
@classmethod
|
||||
def _idle_slot_x_positions(cls) -> dict[str, float]:
|
||||
"""Map each ``idle_slot`` name to its X coordinate past the pen.
|
||||
|
||||
First idle slot lands at ``ICON_CANCEL_X`` (the cancel-slot position,
|
||||
unused in idle since validate/cancel are edit-only). Successive slots
|
||||
are spaced by ``ICON_ARRAY_GAP``, plus any per-slot ``extra_gap_before``."""
|
||||
positions: dict[str, float] = {}
|
||||
next_x = cls.ICON_CANCEL_X
|
||||
for slot in cls.idle_slots:
|
||||
next_x += slot.extra_gap_before
|
||||
positions[slot.name] = next_x
|
||||
next_x += cls.ICON_ARRAY_GAP
|
||||
return positions
|
||||
|
||||
@classmethod
|
||||
def _idle_row_right_edge(cls) -> float:
|
||||
"""Rightmost local-X reserved by this group's idle row. Returns the
|
||||
pen position (``ICON_VALIDATE_X``) when no idle slots are declared
|
||||
so peer queries always get a meaningful number."""
|
||||
positions = cls._idle_slot_x_positions()
|
||||
if not positions:
|
||||
return cls.ICON_VALIDATE_X
|
||||
return max(positions.values())
|
||||
|
||||
@classmethod
|
||||
def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector:
|
||||
"""Choose between two anchor candidates so vertical separation stays
|
||||
@@ -5031,27 +5493,13 @@ class BaseParametricGizmoGroup:
|
||||
from_neg_y, from_neg_x = self.get_local_view_direction(context, world_matrix)
|
||||
return ViewDirection(from_negative_y=from_neg_y, from_negative_x=from_neg_x)
|
||||
|
||||
def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool, pref_enabled: bool) -> bool:
|
||||
"""Update gizmo visibility based on modal state, editing state, and preference.
|
||||
|
||||
Consolidates the common pattern:
|
||||
if hidden_by_modal:
|
||||
gizmo.hide = True
|
||||
else:
|
||||
gizmo.hide = not is_editing or not pref_enabled
|
||||
|
||||
Args:
|
||||
gizmo: The gizmo to update visibility for
|
||||
is_editing: Whether the element is currently being edited
|
||||
pref_enabled: Whether this gizmo type is enabled in preferences
|
||||
|
||||
Returns:
|
||||
True if the gizmo is now visible (not hidden), False otherwise
|
||||
"""
|
||||
def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool) -> bool:
|
||||
"""Hide ``gizmo`` when not editing or when a modal owns the viewport.
|
||||
Returns True if the gizmo is now visible."""
|
||||
if self.is_gizmo_hidden_by_modal(gizmo):
|
||||
gizmo.hide = True
|
||||
return False
|
||||
gizmo.hide = not is_editing or not pref_enabled
|
||||
gizmo.hide = not is_editing
|
||||
return not gizmo.hide
|
||||
|
||||
def get_y_position_for_view(
|
||||
@@ -5291,10 +5739,11 @@ class BaseParametricGizmoGroup:
|
||||
|
||||
if preview_base.any_preview_active(context):
|
||||
return False
|
||||
if _is_transform_modal_active(context):
|
||||
return False
|
||||
if cls.gizmo_pref_name:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
feature_prefs = getattr(prefs.gizmos, cls.gizmo_pref_name, None)
|
||||
if feature_prefs is not None and not getattr(feature_prefs, "enabled", True):
|
||||
if not getattr(prefs.gizmos, cls.gizmo_pref_name, True):
|
||||
return False
|
||||
if len(tool.Blender.get_selected_objects()) != 1:
|
||||
return False
|
||||
@@ -5383,8 +5832,9 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
pass
|
||||
|
||||
# Subclass should define these class attributes for metadata-driven dispatch
|
||||
# If not defined, subclass must override get_props() and get_gizmo_prefs()
|
||||
# Subclass should define these class attributes for metadata-driven dispatch.
|
||||
# ``gizmo_pref_name`` matches a flat BoolProperty field on
|
||||
# ``GizmoPreferences`` and gates the whole gizmo group's poll.
|
||||
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] | None = None
|
||||
gizmo_pref_name: str | None = None # e.g., "door"
|
||||
|
||||
@@ -5419,18 +5869,6 @@ class BaseParametricGizmoGroup:
|
||||
prefs = self.get_addon_prefs()
|
||||
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
|
||||
|
||||
def get_gizmo_prefs(self) -> Any:
|
||||
"""Get gizmo preferences for this element type.
|
||||
|
||||
Subclass can either:
|
||||
1. Define class attribute `gizmo_pref_name` (e.g., "door")
|
||||
2. Override this method directly
|
||||
"""
|
||||
if self.gizmo_pref_name:
|
||||
prefs = self.get_addon_prefs()
|
||||
return getattr(prefs.gizmos, self.gizmo_pref_name)
|
||||
raise NotImplementedError("Subclass must define gizmo_pref_name or override get_gizmo_prefs()")
|
||||
|
||||
def is_setup_complete(self) -> bool:
|
||||
"""Check if gizmo setup has been completed.
|
||||
|
||||
@@ -5654,6 +6092,34 @@ class BaseParametricGizmoGroup:
|
||||
"VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color
|
||||
)
|
||||
|
||||
# Feature-specific edit-row icons. Subclasses declare them via
|
||||
# ``feature_slots``; multi-variant slots create one gizmo per
|
||||
# variant at the same X (e.g. a lock pair, a baseline triplet) and
|
||||
# the subclass picks which is visible per frame. Placeholder slots
|
||||
# only reserve an X position — the subclass creates its own gizmo
|
||||
# there in ``setup_element_specific_gizmos``.
|
||||
for slot in self.feature_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_color = slot.color if slot.color is not None else default_color
|
||||
kwargs = dict(slot.operator_props)
|
||||
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
|
||||
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
|
||||
setattr(self, attr, gz)
|
||||
|
||||
# Idle-mode pen-row extras. Same creation path as feature_slots; the
|
||||
# IDLE branch of ``update_editing_gizmos`` positions and visibility-
|
||||
# gates them, the EDIT branch hides them so the validate/cancel row
|
||||
# owns the X positions.
|
||||
for slot in self.idle_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_color = slot.color if slot.color is not None else default_color
|
||||
kwargs = dict(slot.operator_props)
|
||||
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
|
||||
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
|
||||
setattr(self, attr, gz)
|
||||
|
||||
# ARRAY button — visible during the feature edit lifecycle only (positioned by
|
||||
# ``update_editing_gizmos``). Click commits the current edit and adds a
|
||||
# Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The
|
||||
@@ -5916,14 +6382,65 @@ class BaseParametricGizmoGroup:
|
||||
billboard_rot=billboard_rot,
|
||||
scale=0.30,
|
||||
)
|
||||
# Array gizmo integration is in-progress: the icon binds to
|
||||
# bim.add_array_from_feature_edit but the array-from-parametric-draft
|
||||
# operator + per-feature gizmo positioning haven't fully landed.
|
||||
# Force-hide the icon while parametric-item editing is active to
|
||||
# keep the user from triggering a half-wired add-array flow. Drop
|
||||
# this gate when array integration completes.
|
||||
# Feature slots: per-class IconSlot tuples driven by tuple order.
|
||||
# Whole-feature visibility is gated upstream by ``poll()`` against
|
||||
# ``prefs.gizmos.<feature>``; positioning happens unconditionally
|
||||
# whenever the gizmo group polls visible.
|
||||
slot_positions = self._slot_x_positions()
|
||||
for slot in self.feature_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_x = self.ICON_VALIDATE_X + slot_positions[slot.name]
|
||||
attrs = slot.gizmo_attrs()
|
||||
if slot.variants:
|
||||
# Multi-variant slot: write matrix on every variant member
|
||||
# at the same anchor so a state flip never reveals a stale
|
||||
# pose. The subclass's per-frame hook picks which member
|
||||
# is visible — this loop doesn't toggle hide flags.
|
||||
world_pos = mw @ Vector((slot_x, icon_y, icon_z))
|
||||
matrix = billboarded_at(world_pos, billboard_rot, scale=slot.scale)
|
||||
for attr in attrs:
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is not None:
|
||||
gz.matrix_basis = matrix
|
||||
continue
|
||||
gz = getattr(self, attrs[0], None)
|
||||
if gz is None:
|
||||
continue
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
self.set_icon_gizmo_position(
|
||||
attrs[0],
|
||||
mw=mw,
|
||||
x=slot_x,
|
||||
y=icon_y,
|
||||
z=icon_z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=slot.scale,
|
||||
)
|
||||
# ARRAY button sits past the last feature-specific icon. Slot-based
|
||||
# subclasses derive the right edge from the slot count.
|
||||
if hasattr(self, "array_gizmo"):
|
||||
self.array_gizmo.hide = True
|
||||
self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo)
|
||||
# 30% smaller than the editing-icon-row default (0.50 → 0.35):
|
||||
# the array button is a tertiary affordance compared to the
|
||||
# primary pen / validate / cancel triad, and the smaller
|
||||
# footprint keeps the edit-mode row from sprawling.
|
||||
self.set_icon_gizmo_position(
|
||||
"array_gizmo",
|
||||
mw=mw,
|
||||
x=self.ICON_VALIDATE_X + self._feature_row_right_edge() + self.ICON_ARRAY_GAP,
|
||||
y=icon_y,
|
||||
z=icon_z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=0.35,
|
||||
)
|
||||
# Idle-row icons are hidden in edit — validate / cancel sit at
|
||||
# the same X positions, so showing both would stack icons.
|
||||
for slot in self.idle_slots:
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is not None:
|
||||
gz.hide = True
|
||||
else:
|
||||
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
|
||||
# groups whose edit-mode entry is already provided by another widget
|
||||
@@ -5942,8 +6459,41 @@ class BaseParametricGizmoGroup:
|
||||
self.cancel_gizmo.hide = True
|
||||
if self.cycle_type_operator or self.pick_type_operator:
|
||||
self.cycle_gizmo.hide = True
|
||||
for slot in self.feature_slots:
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is not None:
|
||||
gz.hide = True
|
||||
if hasattr(self, "array_gizmo"):
|
||||
self.array_gizmo.hide = True
|
||||
# Idle slots: position past the pen, apply per-slot visible_when
|
||||
# so state-dependent icons (e.g. toggle_openings) only render
|
||||
# when relevant. Hidden slots STILL consume their X position so
|
||||
# the row layout doesn't shift when state flips.
|
||||
idle_positions = self._idle_slot_x_positions()
|
||||
for slot in self.idle_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_x = self.ICON_VALIDATE_X + idle_positions[slot.name]
|
||||
gate = slot.visible_when
|
||||
visible = True if gate is None else bool(gate(self))
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is None:
|
||||
continue
|
||||
if not visible:
|
||||
gz.hide = True
|
||||
continue
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
self.set_icon_gizmo_position(
|
||||
attr,
|
||||
mw=mw,
|
||||
x=slot_x,
|
||||
y=icon_y,
|
||||
z=icon_z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=slot.scale,
|
||||
)
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
"""Called before drawing - updates gizmos to face camera.
|
||||
@@ -5955,6 +6505,8 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
if not self.is_setup_complete():
|
||||
return
|
||||
if apply_transform_modal_draw_gate(self, context):
|
||||
return
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return
|
||||
@@ -6022,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
|
||||
@@ -6161,6 +6718,8 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
if not self.is_setup_complete():
|
||||
return
|
||||
if apply_transform_modal_draw_gate(self, context):
|
||||
return
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return
|
||||
@@ -6567,6 +7126,8 @@ class BaseIconActionGroup(BillboardingGizmoGroupMixin):
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
if _is_transform_modal_active(context):
|
||||
return False
|
||||
return cls.is_eligible_object(obj)
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
|
||||
@@ -80,9 +80,9 @@ class ViewportData:
|
||||
modes.append(edit_mode)
|
||||
elif element.is_a("IfcGridAxis"):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
modes.append(edit_mode)
|
||||
elif item_mode not in modes:
|
||||
modes.append(item_mode)
|
||||
|
||||
@@ -546,6 +546,13 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
objs = [bpy.data.objects[obj_name]] if obj_name else context.selected_objects
|
||||
self.file = tool.Ifc.get()
|
||||
|
||||
# Tessellated face sets (IfcTriangulatedFaceSet/IfcPolygonalFaceSet) were
|
||||
# introduced in IFC4 and do not exist in IFC2X3. Catch this early so we
|
||||
# don't silently fall back to a faceted brep after stripping materials.
|
||||
if self.ifc_representation_class == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
|
||||
self.report({"ERROR"}, "Tessellated face sets are not supported in IFC2X3.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
for obj in objs:
|
||||
# TODO: write unit tests to see how this bulk operation handles
|
||||
# contradictory ifc_representation_class values and when
|
||||
@@ -883,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):
|
||||
@@ -924,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:
|
||||
@@ -1023,14 +1040,17 @@ 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)
|
||||
data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.Array.get_modifiers_data(array_parent))]
|
||||
data = [(i, data) for i, data in enumerate(tool.Array.get_modifiers_data(array_parent))]
|
||||
# NOTE: there is a way to remove arrays more precisely but it's more complex
|
||||
for i, modifier_data in reversed(data):
|
||||
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
|
||||
children = set(tool.Array.get_children_objects(modifier_data))
|
||||
if children.issubset(selected_objects):
|
||||
with context.temp_override(active_object=array_parent_obj):
|
||||
bpy.ops.bim.remove_array(item=i)
|
||||
@@ -1289,9 +1309,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
all_objects_to_select.add(part_obj)
|
||||
|
||||
# Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
|
||||
all_objects_to_select.update(
|
||||
obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj)
|
||||
)
|
||||
all_objects_to_select.update(obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj))
|
||||
|
||||
# Deselect everything first
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
@@ -2497,9 +2515,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
profile = tool.Ifc.get().by_id(profile_id)
|
||||
if tool.Ifc.get_object(profile): # We are editing an arbitrary profile
|
||||
bpy.ops.bim.edit_arbitrary_profile()
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
bpy.ops.bim.finish_editing_railing_path()
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
bpy.ops.bim.finish_editing_roof_path()
|
||||
elif tool.Model.get_usage_type(element) == "PROFILE":
|
||||
bpy.ops.bim.edit_extrusion_axis()
|
||||
@@ -3168,7 +3186,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
|
||||
product_reps = element.RepresentationMaps
|
||||
item_aspect = {}
|
||||
for product_rep in product_reps:
|
||||
for aspect in product_rep.HasShapeAspects:
|
||||
for aspect in getattr(product_rep, "HasShapeAspects", ()):
|
||||
for aspect_rep in aspect.ShapeRepresentations:
|
||||
if aspect_rep.ContextOfItems != representation.ContextOfItems:
|
||||
continue
|
||||
|
||||
@@ -31,7 +31,9 @@ from . import (
|
||||
external,
|
||||
grid,
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
mep_bend_preview,
|
||||
opening,
|
||||
product,
|
||||
profile,
|
||||
@@ -50,19 +52,28 @@ from . import (
|
||||
|
||||
classes = (
|
||||
array.AddArray,
|
||||
array.DisableEditingArray,
|
||||
array.EditArray,
|
||||
array.CancelEditingArray,
|
||||
array.EnableEditingArray,
|
||||
array.FinishEditingArray,
|
||||
array.ApplyArray,
|
||||
array.RegenerateArray,
|
||||
array.RemoveArray,
|
||||
array.SelectAllArrayObjects,
|
||||
array.SelectArrayParent,
|
||||
array.ArrayParentGizmoClick,
|
||||
array.EditArrayFromChild,
|
||||
array.Input3DCursorXArray,
|
||||
array.Input3DCursorYArray,
|
||||
array.Input3DCursorZArray,
|
||||
array.EnableEditingParametric,
|
||||
array.AddArrayFromFeatureEdit,
|
||||
array.ArrayGizmoClick,
|
||||
array.ToggleArrayMethod,
|
||||
array.RemoveArrayLayerFromEdit,
|
||||
array.InputArrayCount,
|
||||
array.AdjustArrayCount,
|
||||
array.GizmoArrayEdition,
|
||||
array.GizmoArrayChild,
|
||||
product.AddDefaultType,
|
||||
product.AddEmptyType,
|
||||
product.AddOccurrence,
|
||||
@@ -74,6 +85,7 @@ classes = (
|
||||
product.SetActiveType,
|
||||
workspace.Hotkey,
|
||||
workspace.BIM_MT_add_representation_item,
|
||||
wall.AddPerpendicularWall,
|
||||
wall.AddWallsFromSlab,
|
||||
wall.AlignWall,
|
||||
wall.CancelEditingWall,
|
||||
@@ -91,12 +103,18 @@ classes = (
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
wall.GizmoWallAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostToggleOpenings,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoPairDisconnect,
|
||||
wall.GizmoSlabEdition,
|
||||
wall.GizmoSlabUnjoinWalls,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
wall.JoinWallsIntersection,
|
||||
wall.MergeWall,
|
||||
@@ -105,8 +123,7 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.ToggleWallOpenings,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.DisconnectElements,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
@@ -124,6 +141,7 @@ classes = (
|
||||
opening.RemoveBoolean,
|
||||
opening.SelectBoolean,
|
||||
opening.ShowOpenings,
|
||||
opening.ToggleHostOpenings,
|
||||
opening.UpdateOpeningsFocus,
|
||||
profile.ChangeCardinalPoint,
|
||||
profile.ChangeProfileDepth,
|
||||
@@ -139,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,
|
||||
@@ -170,11 +191,16 @@ classes = (
|
||||
prop.BIMDoorProperties,
|
||||
prop.BIMRailingProperties,
|
||||
prop.BIMRoofProperties,
|
||||
prop.BIMSlabProperties,
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPipeSegmentProperties,
|
||||
prop.BIMDuctSegmentProperties,
|
||||
prop.BIMPolylineProperties,
|
||||
prop.BIMExternalParametricGeometryProperties,
|
||||
prop.BIMBendPreviewProperties,
|
||||
prop.BIMWallFilletPreviewProperties,
|
||||
prop.BIMPreviewProperties,
|
||||
prop.BIMParametricEditDialogPrefs,
|
||||
ui.BIM_PT_array,
|
||||
ui.BIM_PT_stair,
|
||||
ui.BIM_PT_wall,
|
||||
@@ -198,7 +224,8 @@ classes = (
|
||||
stair.ToggleStairProperty,
|
||||
stair.AdjustStairTreads,
|
||||
stair.SetStairTreads,
|
||||
stair.CycleStairType,
|
||||
stair.InputStairTreads,
|
||||
stair.PickStairType,
|
||||
stair.GizmoStairEdition,
|
||||
sverchok_modifier.CreateNewSverchokGraph,
|
||||
sverchok_modifier.UpdateDataFromSverchok,
|
||||
@@ -211,7 +238,7 @@ classes = (
|
||||
window.FinishEditingWindow,
|
||||
window.EnableEditingWindow,
|
||||
window.RemoveWindow,
|
||||
window.CycleWindowType,
|
||||
window.PickWindowType,
|
||||
window.GizmoWindowEdition,
|
||||
door.BIM_OT_add_door,
|
||||
door.AddDoor,
|
||||
@@ -220,15 +247,19 @@ classes = (
|
||||
door.EnableEditingDoor,
|
||||
door.RemoveDoor,
|
||||
door.ToggleDoorSwing,
|
||||
door.CycleDoorType,
|
||||
door.PickDoorType,
|
||||
door.GizmoDoorEdition,
|
||||
railing.BIM_OT_add_railing,
|
||||
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,
|
||||
@@ -237,16 +268,39 @@ classes = (
|
||||
roof.AddRoof,
|
||||
roof.CancelEditingRoof,
|
||||
roof.CopyRoofParameters,
|
||||
roof.CycleRoofGenerationMethod,
|
||||
roof.FinishEditingRoof,
|
||||
roof.EnableEditingRoof,
|
||||
roof.CancelEditingRoofPath,
|
||||
roof.FinishEditingRoofPath,
|
||||
roof.EnableEditingRoofPath,
|
||||
roof.GizmoRoofEdition,
|
||||
roof.RemoveRoof,
|
||||
roof.SetGableRoofEdgeAngle,
|
||||
mep.MEPAddObstruction,
|
||||
mep.MEPAddTransition,
|
||||
mep.MEPAddBend,
|
||||
mep.MEPRemoveTerminalFitting,
|
||||
mep.SelectMEPPathMembers,
|
||||
mep.MEPJoinSegments,
|
||||
mep_bend_preview.EnableBendPreview,
|
||||
mep_bend_preview.FinishBendPreview,
|
||||
mep_bend_preview.CancelBendPreview,
|
||||
mep_bend_preview.EnableBendPreviewFromBend,
|
||||
mep_bend_preview.GizmoBendPreview,
|
||||
mep.EnableEditingPipeSegment,
|
||||
mep.FinishEditingPipeSegment,
|
||||
mep.CancelEditingPipeSegment,
|
||||
mep.EnableEditingDuctSegment,
|
||||
mep.FinishEditingDuctSegment,
|
||||
mep.CancelEditingDuctSegment,
|
||||
mep.ExtendPipeSegmentToCursor,
|
||||
mep.ExtendDuctSegmentToCursor,
|
||||
mep.SplitPipeSegmentAtCursor,
|
||||
mep.SplitDuctSegmentAtCursor,
|
||||
mep.GizmoPipeSegmentEdition,
|
||||
mep.GizmoDuctSegmentEdition,
|
||||
mep.GizmoMEPActions,
|
||||
external.ApplyExternalParametricGeometry,
|
||||
)
|
||||
|
||||
@@ -306,6 +360,9 @@ def register():
|
||||
type=prop.BIMExternalParametricGeometryProperties
|
||||
)
|
||||
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
|
||||
bpy.types.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
|
||||
type=prop.BIMParametricEditDialogPrefs
|
||||
)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
@@ -320,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)
|
||||
@@ -331,6 +393,7 @@ def unregister():
|
||||
tool.Parametric.unregister_object_properties()
|
||||
del bpy.types.Object.BIMExternalParametricGeometryProperties
|
||||
del bpy.types.Scene.BIMPreviewProperties
|
||||
del bpy.types.WindowManager.BIMParametricEditDialogPrefs
|
||||
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -15,12 +15,15 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from math import cos, pi, radians, sin, tan
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
import blf
|
||||
import bmesh
|
||||
@@ -41,6 +44,12 @@ 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,
|
||||
DOOR_SWING_ANGLE_MIN,
|
||||
)
|
||||
from bonsai.bim.module.drawing.helper import format_distance
|
||||
|
||||
|
||||
@@ -55,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
|
||||
|
||||
@@ -88,15 +132,9 @@ class ProfileDecorator:
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""mutates original bm (triangulates it)
|
||||
so the triangulation edges will be shown too
|
||||
"""
|
||||
traingulated_bm = bm
|
||||
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
|
||||
|
||||
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
|
||||
|
||||
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -2031,40 +2069,179 @@ class BoundingBoxDecorator:
|
||||
co2.y -= y_overlap / 2 + min_spacing
|
||||
|
||||
|
||||
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")
|
||||
def compute_mep_join_location():
|
||||
"""Midpoint between the closest endpoint pair of two selected MEP
|
||||
segments — the world location where a connecting fitting (bend /
|
||||
transition) would land. Returns ``None`` when prerequisites aren't met
|
||||
(wrong cardinality, mixed non-MEP)."""
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return None
|
||||
for obj in selected:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not tool.System.is_mep_element(element):
|
||||
return None
|
||||
a_start, a_end = tool.Model.get_flow_segment_axis(selected[0])
|
||||
b_start, b_end = tool.Model.get_flow_segment_axis(selected[1])
|
||||
pairs = [(a_start, b_start), (a_start, b_end), (a_end, b_start), (a_end, b_end)]
|
||||
closest = min(pairs, key=lambda p: (p[0] - p[1]).length)
|
||||
return (closest[0] + closest[1]) * 0.5
|
||||
|
||||
|
||||
class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Preview line for the MEP segment extend-to-cursor gizmo. Renders one
|
||||
line from the segment's current end to the cursor's projection on the
|
||||
segment's local Z axis when the extend icon is hovered. Self-gates every
|
||||
draw on the viewport gizmo toggle and the per-feature ``extend`` pref."""
|
||||
|
||||
draw_method = "draw_line"
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
|
||||
def draw_line(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
return
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if active not in selected or len(selected) != 1:
|
||||
return
|
||||
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.model.mep import (
|
||||
GizmoDuctSegmentEdition,
|
||||
GizmoPipeSegmentEdition,
|
||||
)
|
||||
|
||||
if tool.Parametric.is_pipe_segment(element):
|
||||
gizmo_prefs = getattr(prefs.gizmos, "pipe_segment", None)
|
||||
gizmo_cls = GizmoPipeSegmentEdition
|
||||
elif tool.Parametric.is_duct_segment(element):
|
||||
gizmo_prefs = getattr(prefs.gizmos, "duct_segment", None)
|
||||
gizmo_cls = GizmoDuctSegmentEdition
|
||||
else:
|
||||
return
|
||||
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
|
||||
return
|
||||
if not self._cursor_icon_hovered(gizmo_cls, "extend_gizmo", context):
|
||||
return
|
||||
|
||||
current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0
|
||||
line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length)
|
||||
if line is None:
|
||||
return
|
||||
start_world, end_world = line
|
||||
color = tuple(prefs.decorator_color_selected[:3])
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
[(tuple(start_world), tuple(end_world))],
|
||||
color,
|
||||
self.LINE_ALPHA,
|
||||
self.LINE_WIDTH,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _compute_extend_preview_line(
|
||||
matrix_world: Matrix,
|
||||
cursor_world: Vector,
|
||||
current_length: float,
|
||||
) -> tuple[Vector, Vector] | None:
|
||||
"""Returns ``(current_end_world, target_end_world)`` or ``None`` when
|
||||
no extend would happen (degenerate segment, or cursor on the existing
|
||||
end). Target follows the cursor's raw local-Z projection unbounded —
|
||||
the line stays visible past the segment origin (negative local Z)
|
||||
because the user expects to see where they're pointing even when the
|
||||
operator would floor it."""
|
||||
if current_length <= 0:
|
||||
return None
|
||||
cursor_local = matrix_world.inverted() @ cursor_world
|
||||
if abs(cursor_local.z - current_length) < 1e-6:
|
||||
return None
|
||||
current_end_world = matrix_world @ Vector((0.0, 0.0, current_length))
|
||||
target_end_world = matrix_world @ Vector((0.0, 0.0, cursor_local.z))
|
||||
return current_end_world, target_end_world
|
||||
|
||||
|
||||
class BendPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""GPU preview lines for the bend-creation flow.
|
||||
|
||||
Polls on ``scene.BIMPreviewProperties.bend.is_active`` and renders the
|
||||
centerline + leg projections returned by ``mep.compute_bend_preview_polylines``.
|
||||
The two leg lines (segment → tangent point) show how each segment will
|
||||
be shortened; the arc polyline approximates the bend curve. On invalid
|
||||
geometry, draws the two rejected axes in warning colour instead so the
|
||||
user sees why the bend cannot be placed.
|
||||
|
||||
Installed once per Blender session from ``bim/handler.py:load_post``.
|
||||
Cheap to leave running because the first thing ``draw`` does is check
|
||||
``is_active`` and return when False.
|
||||
"""
|
||||
|
||||
LINE_WIDTH_LEG = 1.5
|
||||
LINE_WIDTH_ARC = 2.5
|
||||
LINE_ALPHA = 0.7
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
scene = context.scene
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
props = preview.bend if preview is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
try:
|
||||
start_element = ifc_file.by_id(props.start_segment_id)
|
||||
end_element = ifc_file.by_id(props.end_segment_id)
|
||||
except Exception:
|
||||
return
|
||||
start_obj = tool.Ifc.get_object(start_element) if start_element else None
|
||||
end_obj = tool.Ifc.get_object(end_element) if end_element else None
|
||||
if start_obj is None or end_obj is None:
|
||||
return
|
||||
|
||||
# Late import: decorator.py loads at addon enable but mep.py imports
|
||||
# this module for the extend preview, so a module-level import would
|
||||
# cycle.
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
preview = cached_compute_bend_preview_polylines(
|
||||
start_obj, end_obj, props.start_length, props.end_length, props.radius
|
||||
)
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
if not preview["valid"]:
|
||||
warning_color = tuple(prefs.decorator_color_error[:3])
|
||||
axes = preview.get("invalid_axes") or []
|
||||
if axes:
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
arc_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
leg_a_far, leg_a_end = preview["leg_a"]
|
||||
leg_b_far, leg_b_end = preview["leg_b"]
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
[(tuple(leg_a_far), tuple(leg_a_end)), (tuple(leg_b_far), tuple(leg_b_end))],
|
||||
leg_color,
|
||||
self.LINE_ALPHA,
|
||||
self.LINE_WIDTH_LEG,
|
||||
)
|
||||
|
||||
arc = preview["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
|
||||
|
||||
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
@@ -2128,15 +2305,15 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
(tuple(far_a), tuple(tangent_a)),
|
||||
(tuple(far_b), tuple(tangent_b)),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, legs, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
|
||||
arc = geom.get("arc") or []
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, arc_segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
elif geom.get("invalid_axes"):
|
||||
axes = geom["invalid_axes"]
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
@@ -2153,12 +2330,12 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
(tuple(far_a), tuple(geom["tangent_a"])),
|
||||
(tuple(far_b), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, legs, leg_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
|
||||
|
||||
arc = geom["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
|
||||
# Dim construction lines from arc_center to each tangent point so
|
||||
# the radius reads as concrete during drag.
|
||||
@@ -2168,7 +2345,9 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
(tuple(arc_center), tuple(geom["tangent_a"])),
|
||||
(tuple(arc_center), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
|
||||
draw_polyline_segments(
|
||||
context, construction, arc_color, self.CONSTRUCTION_ALPHA, self.LINE_WIDTH_CONSTRUCTION
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _far_endpoint(reference_line, intersection):
|
||||
@@ -2177,3 +2356,651 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
|
||||
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
|
||||
return p2 if d2 >= d1 else p1
|
||||
|
||||
|
||||
class _DoorSwingArc(NamedTuple):
|
||||
"""Parameters for one swing-arc draw call in door-local space."""
|
||||
|
||||
hinge_x: float
|
||||
hinge_y: float
|
||||
panel_width: float
|
||||
x_mirror: bool
|
||||
|
||||
|
||||
def _visible_arcs(door_type: str, overall_width: float, lining_offset: float) -> list[_DoorSwingArc]:
|
||||
"""Arc specs for the parametric door swing visualisation, agnostic of
|
||||
edit-mode state so the readonly preview and the editor view stay aligned.
|
||||
|
||||
Empty only for sliding-door types; unknown ``door_type`` values fall
|
||||
through to a single left-hinged arc."""
|
||||
if "SLIDING" in door_type:
|
||||
return []
|
||||
is_double = "DOUBLE_DOOR" in door_type
|
||||
is_right_single = door_type.endswith("RIGHT") and not is_double
|
||||
arcs = [
|
||||
_DoorSwingArc(
|
||||
hinge_x=overall_width if is_right_single else 0.0,
|
||||
hinge_y=lining_offset,
|
||||
panel_width=overall_width / 2 if is_double else overall_width,
|
||||
x_mirror=is_right_single,
|
||||
)
|
||||
]
|
||||
if is_double:
|
||||
arcs.append(
|
||||
_DoorSwingArc(
|
||||
hinge_x=overall_width,
|
||||
hinge_y=lining_offset,
|
||||
panel_width=overall_width / 2,
|
||||
x_mirror=True,
|
||||
)
|
||||
)
|
||||
return arcs
|
||||
|
||||
|
||||
# Unit quarter-arc samples shared with the edit-mode swing gizmo so the
|
||||
# readonly arc traces the same curve. Re-scaled per draw via the per-arc
|
||||
# transform.
|
||||
_DOOR_SWING_ARC_ANGLE_MIN_RAD = math.radians(DOOR_SWING_ANGLE_MIN)
|
||||
_DOOR_SWING_ARC_ANGLE_RANGE_RAD = math.radians(DOOR_SWING_ANGLE_MAX) - _DOOR_SWING_ARC_ANGLE_MIN_RAD
|
||||
_DOOR_SWING_ARC_UNIT_POINTS: tuple[Vector, ...] = tuple(
|
||||
Vector(
|
||||
(
|
||||
math.cos(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
|
||||
math.sin(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
|
||||
0.0,
|
||||
)
|
||||
)
|
||||
for _i in range(ARC_SEGMENTS + 1)
|
||||
)
|
||||
|
||||
|
||||
class DoorSwingReadonlyDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Always-on swing-arc preview for the active Bonsai-parametric IfcDoor
|
||||
when it is not currently in parametric edit mode. Matches the visual
|
||||
contract of the parametric door's swing-arc gizmos so the hinge side
|
||||
and opening direction can be read without entering edit mode.
|
||||
|
||||
Silent-skip cases (no draw, no error):
|
||||
|
||||
- active object missing / not selected / not an IfcDoor;
|
||||
- door is mid-edit (the swing gizmo is already painting the arc);
|
||||
- door has no ``BBIM_Door`` pset (legacy import, never edited in Bonsai)."""
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
obj = context.active_object
|
||||
if obj is None or not obj.select_get():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not element.is_a("IfcDoor"):
|
||||
return
|
||||
props = getattr(obj, "BIMDoorProperties", None)
|
||||
if props is not None and props.is_editing:
|
||||
return
|
||||
pset = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Door")
|
||||
if not pset:
|
||||
return
|
||||
data = pset.get("data_dict")
|
||||
if not data:
|
||||
return
|
||||
door_type = data.get("door_type", "")
|
||||
overall_width_project = data.get("overall_width", 0.0)
|
||||
lining_offset_project = (data.get("lining_properties") or {}).get("lining_offset", 0.0)
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
overall_width = overall_width_project * si_conversion
|
||||
lining_offset = lining_offset_project * si_conversion
|
||||
specs = _visible_arcs(door_type, overall_width, lining_offset)
|
||||
if not specs:
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
main_color = tuple(prefs.decorator_color_special[:3])
|
||||
mw = obj.matrix_world
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for spec in specs:
|
||||
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if spec.x_mirror else Matrix.Identity(4)
|
||||
transform = (
|
||||
Matrix.Translation(Vector((spec.hinge_x, spec.hinge_y, 0.0)))
|
||||
@ Matrix.Scale(spec.panel_width, 4)
|
||||
@ x_flip
|
||||
)
|
||||
world_main = mw @ transform
|
||||
pts = [world_main @ p for p in _DOOR_SWING_ARC_UNIT_POINTS]
|
||||
for i in range(len(pts) - 1):
|
||||
segments.append((tuple(pts[i]), tuple(pts[i + 1])))
|
||||
draw_polyline_segments(context, segments, main_color, self.LINE_ALPHA, self.LINE_WIDTH)
|
||||
|
||||
|
||||
_BBOX_EDGES = (
|
||||
(0, 1), (1, 2), (2, 3), (3, 0),
|
||||
(4, 5), (5, 6), (6, 7), (7, 4),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7),
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def bbox_world_edges(
|
||||
obj: bpy.types.Object,
|
||||
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
|
||||
"""Return world-space (start, end) tuples for the 12 edges of ``obj``'s
|
||||
bounding box. Empty list if the object has no bound_box (e.g. Empties)."""
|
||||
if not obj.bound_box:
|
||||
return []
|
||||
mw = obj.matrix_world
|
||||
corners = [mw @ Vector(c) for c in obj.bound_box]
|
||||
return [(tuple(corners[a]), tuple(corners[b])) for a, b in _BBOX_EDGES]
|
||||
|
||||
|
||||
def draw_polyline_segments(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
alpha: float,
|
||||
line_width: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` as one anti-aliased LINES batch in world space."""
|
||||
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(start)
|
||||
verts.append(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, alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
_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
|
||||
|
||||
@@ -37,8 +37,9 @@ 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.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMDoorProperties
|
||||
@@ -580,7 +581,7 @@ class _DoorEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -629,7 +630,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def remove_door_on_object(self, obj: bpy.types.Object) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
if not tool.Parametric.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
props.is_editing = False
|
||||
@@ -644,12 +645,8 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
|
||||
class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Toggle door swing direction and optionally flip door geometry.
|
||||
|
||||
Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction"""
|
||||
|
||||
bl_idname = "bim.toggle_door_swing"
|
||||
bl_label = "Toggle Door Swing"
|
||||
bl_label = "Change Door Swing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
|
||||
@@ -660,6 +657,15 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str:
|
||||
if properties.flip_geometry:
|
||||
return (
|
||||
"Swing the door from the opposite side of the wall. "
|
||||
"Shift+click: mirror the door without changing which side it opens to"
|
||||
)
|
||||
return "Move the door hinge to the opposite side"
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
self.skip_direction_change = event.shift
|
||||
return self.execute(context)
|
||||
@@ -686,7 +692,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"CANCELLED"}
|
||||
|
||||
is_door = tool.Blender.Modifier.is_door(element)
|
||||
is_door = tool.Parametric.is_door(element)
|
||||
|
||||
if self.flip_geometry:
|
||||
tool.Geometry.flip_object(obj, self.flip_local_axes)
|
||||
@@ -700,11 +706,11 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available door types. Shift+click to cycle in reverse."""
|
||||
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a door type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_door_type"
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_idname = "bim.pick_door_type"
|
||||
bl_label = "Pick Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_door
|
||||
@@ -713,7 +719,7 @@ class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin)
|
||||
type_attr = "door_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
@@ -726,7 +732,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_door"
|
||||
finish_editing_operator = "bim.finish_editing_door"
|
||||
cancel_editing_operator = "bim.cancel_editing_door"
|
||||
cycle_type_operator = "bim.cycle_door_type"
|
||||
pick_type_operator = "bim.pick_door_type"
|
||||
|
||||
# Declarative dimension gizmo configuration with visibility and position
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
@@ -833,6 +839,36 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
p.get_transom_window_center_z(),
|
||||
),
|
||||
),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
# Big quarter-arc hit shapes cover much of the door face — without a
|
||||
# negative select_bias they would steal clicks from the small dimension
|
||||
# and edit gizmos drawn on top of them.
|
||||
SWING_ARC_SELECT_BIAS = -1000.0
|
||||
swing_arc_operator = "bim.toggle_door_swing"
|
||||
|
||||
swing_arc_props = [
|
||||
gizmo.SwingArcConfig(
|
||||
name="primary",
|
||||
visibility_condition=lambda p: p.is_editing and "SLIDING" not in p.door_type,
|
||||
hinge_x=lambda p: (
|
||||
p.overall_width if p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type else 0.0
|
||||
),
|
||||
hinge_y=lambda p: p.lining_offset,
|
||||
panel_width=lambda p: p.overall_width / 2 if "DOUBLE_DOOR" in p.door_type else p.overall_width,
|
||||
x_mirror=lambda p: p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type,
|
||||
),
|
||||
gizmo.SwingArcConfig(
|
||||
name="secondary",
|
||||
visibility_condition=lambda p: p.is_editing
|
||||
and "DOUBLE_DOOR" in p.door_type
|
||||
and "SLIDING" not in p.door_type,
|
||||
hinge_x=lambda p: p.overall_width,
|
||||
hinge_y=lambda p: p.lining_offset,
|
||||
panel_width=lambda p: p.overall_width / 2,
|
||||
x_mirror=lambda _p: True,
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_door_props
|
||||
@@ -840,7 +876,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for door icon positioning.
|
||||
@@ -858,22 +894,20 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
return (furthest_y, furthest_y)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create door-specific swing arc gizmos."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
inactive_color = prefs.decorator_color_background[:3]
|
||||
special_color = prefs.decorator_color_special[:3]
|
||||
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
|
||||
|
||||
self.gizmo_door_type = self.create_arc_gizmo(
|
||||
special_color,
|
||||
"bim.toggle_door_swing",
|
||||
flip_geometry=False,
|
||||
)
|
||||
self.gizmo_flip_arc = self.create_arc_gizmo(
|
||||
inactive_color,
|
||||
"bim.toggle_door_swing",
|
||||
flip_geometry=True,
|
||||
flip_local_axes="XY",
|
||||
)
|
||||
Stored as ``self.gizmo_swing_arc_<name>`` and ``self.gizmo_swing_arc_<name>_flip``
|
||||
and pinned to ``SWING_ARC_SELECT_BIAS`` so other door gizmos win selection."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
main_color = prefs.decorator_color_special[:3]
|
||||
flip_color = prefs.decorator_color_background[:3]
|
||||
for cfg in self.swing_arc_props:
|
||||
main = self.create_arc_gizmo(main_color, self.swing_arc_operator, flip_geometry=False)
|
||||
flip = self.create_arc_gizmo(flip_color, self.swing_arc_operator, flip_geometry=True)
|
||||
for gz in (main, flip):
|
||||
gz.select_bias = self.SWING_ARC_SELECT_BIAS
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}", main)
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
|
||||
@@ -892,29 +926,23 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self._update_view_dependent_dimensions(context, mw, props)
|
||||
|
||||
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
|
||||
"""Update swing gizmo position and color based on editing state."""
|
||||
prefs = self.get_addon_prefs()
|
||||
door_gizmo_prefs = prefs.gizmos.door
|
||||
|
||||
door_type_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc
|
||||
)
|
||||
flip_arc_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc
|
||||
)
|
||||
|
||||
if not door_type_visible and not flip_arc_visible:
|
||||
return
|
||||
|
||||
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
|
||||
base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(
|
||||
props.overall_width, 4
|
||||
)
|
||||
|
||||
if door_type_visible:
|
||||
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
|
||||
self.gizmo_door_type.color = prefs.decorations_colour[:3]
|
||||
|
||||
if flip_arc_visible:
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
|
||||
"""Position each declared swing-arc pair per its config + props state."""
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
for cfg in self.swing_arc_props:
|
||||
main = getattr(self, f"gizmo_swing_arc_{cfg.name}")
|
||||
flip = getattr(self, f"gizmo_swing_arc_{cfg.name}_flip")
|
||||
show = cfg.visibility_condition(props)
|
||||
main_visible = self.update_gizmo_visibility(main, show)
|
||||
flip_visible = self.update_gizmo_visibility(flip, show)
|
||||
if not (main_visible or flip_visible):
|
||||
continue
|
||||
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if cfg.x_mirror(props) else Matrix.Identity(4)
|
||||
transform = (
|
||||
Matrix.Translation(V_(cfg.hinge_x(props), cfg.hinge_y(props), 0))
|
||||
@ Matrix.Scale(cfg.panel_width(props), 4)
|
||||
@ x_flip
|
||||
)
|
||||
if main_visible:
|
||||
main.matrix_basis = mw @ transform
|
||||
if flip_visible:
|
||||
flip.matrix_basis = mw @ transform @ mirror_y
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# 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 single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
|
||||
|
||||
One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
|
||||
parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
|
||||
guards host-host pairings so this gizmo never overlaps with the existing
|
||||
wall-join / extend-vertically gizmos. The positioner dispatches on element
|
||||
type — walls use axis-projection + camera-facing-Y math (which requires the
|
||||
parametric layer-set); slabs and roofs use a world-Z face bias driven by
|
||||
the void object's elevation against the host's bounding box."""
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model.wall import (
|
||||
_get_wall_geom_cached,
|
||||
_wall_camera_facing_icon_y,
|
||||
_wall_gizmo_poll_gate,
|
||||
_WallGeomCachedBillboardingMixin,
|
||||
)
|
||||
|
||||
|
||||
def is_supported_host(element) -> bool:
|
||||
"""Total predicate (None → False). Walls accept either a parametric
|
||||
LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
|
||||
layer-set for the anchor math); slabs and roofs only need the bound
|
||||
box so any IfcSlab / IfcRoof qualifies regardless of parametric
|
||||
modifier state."""
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
||||
|
||||
|
||||
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
||||
selected + IFC entity lookup + supported-host predicate. Returns the
|
||||
active element on success, ``None`` on any failure — callers chain their
|
||||
feature-specific checks past the early-return."""
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return None
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != n_selected:
|
||||
return None
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if not element or not is_supported_host(element):
|
||||
return None
|
||||
return element
|
||||
|
||||
|
||||
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when a host element (wall / slab / roof) is the active object
|
||||
and exactly one other selected object is *not* itself a host.
|
||||
|
||||
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
|
||||
projected location on the host. A click dispatches ``bim.add_opening``,
|
||||
which handles any element exposing the ``HasOpenings`` inverse.
|
||||
|
||||
Per-frame positioning keeps the icon facing the camera as the viewport
|
||||
orbits."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_add_opening"
|
||||
bl_label = "Host Add Opening Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=2)
|
||||
if element is None:
|
||||
return False
|
||||
# The operator itself filters on HasOpenings, but checking here keeps
|
||||
# the icon from appearing on host classes that can't accept openings
|
||||
# in the active IFC schema.
|
||||
if not hasattr(element, "HasOpenings"):
|
||||
return False
|
||||
active = context.active_object
|
||||
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
|
||||
# Host + host pairings are claimed by host-specific gizmos (wall-join,
|
||||
# extend-vertical, …) — suppress here so the add-opening icon never
|
||||
# stacks on top of them.
|
||||
if is_supported_host(tool.Ifc.get_entity(other)):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.add_opening_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
other = next((o for o in selected if o is not host_obj), None)
|
||||
if not other:
|
||||
return
|
||||
element = tool.Ifc.get_entity(host_obj)
|
||||
if not element:
|
||||
return
|
||||
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
world_pos = wall_anchor(context, self, host_obj, other)
|
||||
else:
|
||||
world_pos = layer3_anchor(host_obj, other)
|
||||
if world_pos is None:
|
||||
return
|
||||
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
|
||||
|
||||
|
||||
def wall_anchor(
|
||||
context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
|
||||
) -> Vector | None:
|
||||
"""World-space anchor for the add-opening icon on a wall host: void origin
|
||||
projected onto the wall reference-line X (clamped to wall extents), lifted to
|
||||
the camera-facing wall-local Y."""
|
||||
geom = _get_wall_geom_cached(group, wall_obj)
|
||||
if not geom:
|
||||
return None
|
||||
mw = wall_obj.matrix_world
|
||||
wall_local = mw.inverted() @ other.matrix_world.translation
|
||||
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
|
||||
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
|
||||
base_world = mw @ Vector((local_x, icon_y, 0.0))
|
||||
top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
|
||||
return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
|
||||
|
||||
|
||||
def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
|
||||
"""World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
|
||||
void's world XY, lifted just above the host's top face. Predictable height
|
||||
regardless of where the void sits vertically — clicking the icon places the
|
||||
opening at the void's XY, and the operator handles the actual cut depth."""
|
||||
bbox = tool.Blender.get_object_world_bounding_box(host_obj)
|
||||
anchor_xy = other.matrix_world.translation.xy
|
||||
top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((anchor_xy.x, anchor_xy.y, top_z))
|
||||
|
||||
|
||||
def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
|
||||
"""Object origin XY, lifted just above the topmost mesh vertex. Tracks
|
||||
the parametric origin (useful reference even when the mesh extends
|
||||
asymmetrically) and the visible top face (stays clear of sloped or
|
||||
stepped bodies)."""
|
||||
origin = host_obj.matrix_world.translation
|
||||
top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((origin.x, origin.y, top_z))
|
||||
|
||||
|
||||
class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Fallback toggle-openings icon for hosts that lack their own
|
||||
parametric-edit toolbar — slabs today, plus any foreign-authored
|
||||
IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
|
||||
poll for it). Walls and parametric roofs already render an idle-row
|
||||
toggle next to the pen and are excluded from this poll.
|
||||
|
||||
When slab parametric-edit lands the slab branch will pen-row-handle
|
||||
its own toggle; updating the exclusion predicate here is the only
|
||||
migration step needed."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
|
||||
bl_label = "Host Toggle Openings Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=1)
|
||||
if element is None:
|
||||
return False
|
||||
if not tool.Geometry.has_openings(element):
|
||||
return False
|
||||
# Skip when a per-feature parametric-edit gizmo already surfaces
|
||||
# an idle-row toggle for this element — walls and parametric roofs
|
||||
# both render their own toggle in the pen row.
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
return False
|
||||
if tool.Parametric.is_roof(element):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.toggle_openings_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
|
||||
host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
# 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.
|
||||
|
||||
"""Bend-preview lifecycle for MEP segment joins.
|
||||
|
||||
Holds the four lifecycle operators (Enable / Finish / Cancel /
|
||||
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
|
||||
tunable dimensions and validate/cancel icons during preview. Draft state
|
||||
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
|
||||
for cross-element previews).
|
||||
|
||||
The geometry math (``compute_bend_preview_polylines``,
|
||||
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
|
||||
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
|
||||
it; this module imports the polyline helper for per-frame gizmo
|
||||
positioning. The GPU lines themselves are drawn by
|
||||
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
|
||||
sibling decorators."""
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.mep import (
|
||||
_is_bend_fitting,
|
||||
_n_mep_selected,
|
||||
cached_compute_bend_preview_polylines,
|
||||
segments_are_parallel,
|
||||
validate_bend_preconditions,
|
||||
)
|
||||
|
||||
|
||||
class EnableBendPreview(bpy.types.Operator):
|
||||
"""Enter bend-preview mode for two selected MEP segments. Populates
|
||||
scene.BIMPreviewProperties.bend with segment IFC ids and default
|
||||
start_length / end_length / radius; no IFC mutation until finish."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview"
|
||||
bl_label = "Enter Bend Preview"
|
||||
bl_description = "Begin tuning bend parameters before committing the bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not _n_mep_selected(2):
|
||||
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
|
||||
return {"CANCELLED"}
|
||||
other = next((o for o in selected if o is not active), None)
|
||||
if other is None:
|
||||
self.report({"ERROR"}, "Two MEP segments must be selected.")
|
||||
return {"CANCELLED"}
|
||||
active_element = tool.Ifc.get_entity(active)
|
||||
other_element = tool.Ifc.get_entity(other)
|
||||
if active_element is None or other_element is None:
|
||||
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
|
||||
return {"CANCELLED"}
|
||||
if segments_are_parallel(active, other):
|
||||
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Pre-check the same preconditions MEPAddBend enforces so the user
|
||||
# sees the rejection here rather than after tuning a doomed preview.
|
||||
precondition_error = validate_bend_preconditions(active_element, other_element)
|
||||
if precondition_error is not None:
|
||||
self.report({"ERROR"}, precondition_error)
|
||||
return {"CANCELLED"}
|
||||
|
||||
preview_base.sync_uncommitted_moves([active, other])
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
# Auto-cancel any prior preview so re-clicking join on a different
|
||||
# pair doesn't silently commit the previous tuning.
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
props.start_segment_id = active_element.id()
|
||||
props.end_segment_id = other_element.id()
|
||||
props.start_length = 0.1
|
||||
props.end_length = 0.1
|
||||
props.radius = 0.2
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishBendPreview(bpy.types.Operator):
|
||||
"""Commit the previewed bend with the tuned parameters and exit preview.
|
||||
|
||||
Preview state survives a failed commit so the user can re-tune without
|
||||
re-selecting."""
|
||||
|
||||
bl_idname = "bim.finish_bend_preview"
|
||||
bl_label = "Apply Bend"
|
||||
bl_description = "Commit the bend with the previewed parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
return preview_base.commit_preview(
|
||||
self,
|
||||
context,
|
||||
"bend",
|
||||
"mep_add_bend",
|
||||
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
|
||||
)
|
||||
|
||||
|
||||
class CancelBendPreview(bpy.types.Operator):
|
||||
"""Exit bend preview without committing."""
|
||||
|
||||
bl_idname = "bim.cancel_bend_preview"
|
||||
bl_label = "Cancel Bend"
|
||||
bl_description = "Discard the previewed bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
preview_base.clear_preview_state(props)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableBendPreviewFromBend(bpy.types.Operator):
|
||||
"""Re-open the bend preview on an existing bend fitting.
|
||||
|
||||
Resolves the two connected segments via the bend's ports +
|
||||
``IfcRelConnectsPorts``, reads parametric values back from the bend's
|
||||
``BBIM_Fitting`` pset, and flags the preview so committing replaces
|
||||
the existing bend in place."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview_from_bend"
|
||||
bl_label = "Edit Bend"
|
||||
bl_description = "Re-open the bend preview to retune an existing bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
cls.poll_message_set("No active object.")
|
||||
return False
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None or not _is_bend_fitting(element):
|
||||
cls.poll_message_set("Active object must be a bend fitting.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
active = context.active_object
|
||||
bend_element = tool.Ifc.get_entity(active)
|
||||
if bend_element is None or not _is_bend_fitting(bend_element):
|
||||
self.report({"ERROR"}, "Active object is not a bend fitting.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
connected_segments: list = []
|
||||
for port in tool.System.get_ports(bend_element):
|
||||
connected_port = tool.System.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
related = tool.System.get_port_relating_element(connected_port)
|
||||
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
|
||||
connected_segments.append(related)
|
||||
|
||||
if len(connected_segments) != 2:
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Read parametric values from the bend type's BBIM_Fitting pset. The
|
||||
# type carries the canonical parameters; querying the occurrence
|
||||
# would force a get_type round-trip and miss user-edited types.
|
||||
bend_type = ifcopenshell.util.element.get_type(bend_element)
|
||||
if bend_type is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
|
||||
return {"CANCELLED"}
|
||||
bend_type_obj = tool.Ifc.get_object(bend_type)
|
||||
if bend_type_obj is None:
|
||||
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
|
||||
return {"CANCELLED"}
|
||||
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
|
||||
if bbim is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
|
||||
return {"CANCELLED"}
|
||||
data = bbim.get("data_dict", {})
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
# Segment order is load-bearing: the bend's lateral sign and z-axis
|
||||
# flip are derived from which segment is "start" vs "end". Re-edit
|
||||
# must reuse the same pairing as the original create so the recreate
|
||||
# lands at the same orientation.
|
||||
start_segment, end_segment = connected_segments
|
||||
props.start_segment_id = start_segment.id()
|
||||
props.end_segment_id = end_segment.id()
|
||||
# Pset values are in IFC native units; scene units come from si_conversion.
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
|
||||
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
|
||||
props.radius = float(data.get("radius", 0.2)) * si_conversion
|
||||
props.editing_bend_id = bend_element.id()
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _bend_preview_segments(context):
|
||||
"""Resolve the two segment objects from the scene-level preview props.
|
||||
|
||||
Re-resolves by IFC id each frame so undo / file reload during preview
|
||||
never dangles a stale bpy reference."""
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None or not props.is_active:
|
||||
return None, None
|
||||
try:
|
||||
start_element = ifc_file.by_id(props.start_segment_id)
|
||||
end_element = ifc_file.by_id(props.end_segment_id)
|
||||
except Exception:
|
||||
return None, None
|
||||
start_obj = tool.Ifc.get_object(start_element) if start_element else None
|
||||
end_obj = tool.Ifc.get_object(end_element) if end_element else None
|
||||
return start_obj, end_obj
|
||||
|
||||
|
||||
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
|
||||
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
|
||||
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
|
||||
draws + drags along local +X by convention."""
|
||||
x = x_direction.normalized()
|
||||
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
|
||||
y = (seed - x * seed.dot(x)).normalized()
|
||||
z = x.cross(y)
|
||||
mat = Matrix.Identity(4)
|
||||
mat[0][:3] = (x.x, y.x, z.x)
|
||||
mat[1][:3] = (x.y, y.y, z.y)
|
||||
mat[2][:3] = (x.z, y.z, z.z)
|
||||
mat.translation = location
|
||||
return mat
|
||||
|
||||
|
||||
class GizmoBendPreview(bpy.types.GizmoGroup):
|
||||
"""Interactive gizmo group for the bend preview flow.
|
||||
|
||||
Three dimension widgets drag start_length / end_length / radius; two
|
||||
icon gizmos commit or cancel. When the geometry is degenerate the
|
||||
dimensions and validate hide but cancel stays visible so the user
|
||||
always has an exit."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_bend_preview"
|
||||
bl_label = "Bend Preview Gizmos"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
ICON_SCALE: ClassVar[float] = 0.375
|
||||
ICON_SPACING_X: ClassVar[float] = 0.4
|
||||
ICON_Z_OFFSET: ClassVar[float] = 1.5
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
preview = getattr(context.scene, "BIMPreviewProperties", None)
|
||||
props = preview.bend if preview is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return False
|
||||
try:
|
||||
ifc_file.by_id(props.start_segment_id)
|
||||
ifc_file.by_id(props.end_segment_id)
|
||||
except (RuntimeError, KeyError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context):
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
default_color = tuple(prefs.decorations_colour[:3])
|
||||
highlight_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
_props = preview_base.make_props_callback("bend")
|
||||
|
||||
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
|
||||
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
|
||||
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
|
||||
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
|
||||
gz.axis = Vector((1, 0, 0))
|
||||
gz.invert_delta = invert_delta
|
||||
gz.delta_scale = 1.0
|
||||
gz.prop_name = prop_name
|
||||
gz.gizmo_group = self
|
||||
gz.color = default_color
|
||||
gz.color_highlight = highlight_color
|
||||
gz.alpha = 1.0
|
||||
gz.use_draw_modal = True
|
||||
gz.use_draw_scale = False
|
||||
gz.text_offset_sign = 1
|
||||
gz.text_alignment = gizmo.TextAlignment.CENTER
|
||||
gz.show_start_arrow = False
|
||||
gz.show_end_arrow = True
|
||||
gz.show_extension_lines = False
|
||||
gz.text_formatter = None
|
||||
return gz
|
||||
|
||||
self.start_dim = setup_dimension("start_length", "Start Length")
|
||||
self.end_dim = setup_dimension("end_length", "End Length")
|
||||
self.radius_dim = setup_dimension("radius", "Radius")
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
|
||||
self.validate_icon.use_draw_scale = False
|
||||
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
|
||||
self.validate_icon.color_highlight = highlight_color
|
||||
self.validate_icon.target_set_operator("bim.finish_bend_preview")
|
||||
|
||||
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
|
||||
self.cancel_icon.use_draw_scale = False
|
||||
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
|
||||
self.cancel_icon.color_highlight = highlight_color
|
||||
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
|
||||
|
||||
def refresh(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def _position_gizmos(self, context):
|
||||
"""Place gizmos at the bend intersection using the current scene
|
||||
props. Cancel stays visible on degenerate geometry so the user
|
||||
always has an exit; the other widgets hide when there's no defined
|
||||
tangent / arc to anchor them on."""
|
||||
start_obj, end_obj = _bend_preview_segments(context)
|
||||
if start_obj is None or end_obj is None:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
preview = cached_compute_bend_preview_polylines(
|
||||
start_obj, end_obj, props.start_length, props.end_length, props.radius
|
||||
)
|
||||
if not preview["valid"]:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
|
||||
gz.hide = True
|
||||
self.cancel_icon.hide = False
|
||||
axes = preview.get("invalid_axes") or []
|
||||
if axes:
|
||||
intersection_point = axes[0][1]
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
return
|
||||
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = False
|
||||
|
||||
leg_a_far, leg_a_end = preview["leg_a"]
|
||||
leg_b_far, leg_b_end = preview["leg_b"]
|
||||
toward_bend_a = (
|
||||
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
toward_bend_b = (
|
||||
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
|
||||
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
|
||||
|
||||
# axis is set in world space every frame so the drag projection
|
||||
# matches the visual regardless of either segment's matrix_world.
|
||||
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
|
||||
self.start_dim.axis = -toward_bend_a
|
||||
self.start_dim.set_dimension_length(props.start_length)
|
||||
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
|
||||
self.end_dim.axis = -toward_bend_b
|
||||
self.end_dim.set_dimension_length(props.end_length)
|
||||
|
||||
arc = preview["arc"]
|
||||
if len(arc) >= 3:
|
||||
mid = len(arc) // 2
|
||||
chord_mid = (arc[0] + arc[-1]) * 0.5
|
||||
toward_mid = arc[mid] - chord_mid
|
||||
if toward_mid.length > 1e-6:
|
||||
toward_mid = toward_mid.normalized()
|
||||
half_chord = (arc[-1] - arc[0]).length * 0.5
|
||||
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
|
||||
arc_center = chord_mid - toward_mid * center_dist
|
||||
radial_out = arc[mid] - arc_center
|
||||
if radial_out.length > 1e-6:
|
||||
radial_out.normalize()
|
||||
inward = -radial_out
|
||||
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
|
||||
self.radius_dim.axis = inward
|
||||
self.radius_dim.set_dimension_length(props.radius)
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
|
||||
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
|
||||
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from math import radians
|
||||
@@ -207,6 +209,21 @@ def _get_cached_world_draw_data(
|
||||
# handle each call), so they stay drawable across frames.
|
||||
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
|
||||
# density stays coherent across zoom. Dash + gap = period; dash_width controls
|
||||
# the "on" portion.
|
||||
_DASH_PERIOD_METERS: float = 0.20
|
||||
_DASH_WIDTH_METERS: float = 0.10
|
||||
# Solid front pass is rendered wider than the dashed back pass so its halo
|
||||
# overpowers the dashed center on visible edges even when the WIRE-display
|
||||
# overlay biases the depth buffer at outline pixels.
|
||||
_DASH_LINE_WIDTH: float = 1.5
|
||||
_SOLID_LINE_WIDTH: float = 2.5
|
||||
# Per-iteration default line width used by every non-occlusion draw call in
|
||||
# this decorator's ``__call__``. Restored after each occlusion pair so the
|
||||
# next draw isn't silently inheriting the wider solid-pass override.
|
||||
_DEFAULT_LINE_WIDTH: float = 2.0
|
||||
|
||||
|
||||
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
|
||||
uid = cache_key[0]
|
||||
@@ -229,9 +246,15 @@ class FilledOpeningGenerator:
|
||||
filling_obj: bpy.types.Object,
|
||||
voided_obj: bpy.types.Object,
|
||||
target: Optional[Vector] = None,
|
||||
preserve_placement: bool = False,
|
||||
) -> Union[None, str]:
|
||||
"""
|
||||
:param target: Target opening position. If ommited, cursor position is used.
|
||||
:param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is
|
||||
and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening
|
||||
is still created at the filling's current world position. Useful
|
||||
when the caller (e.g. the SHIFT-add-opening gizmo flow) has
|
||||
already positioned the filling intentionally.
|
||||
:return: None if there was no errors, otherwise returns a string with error message.
|
||||
"""
|
||||
props = tool.Model.get_model_props()
|
||||
@@ -253,7 +276,7 @@ class FilledOpeningGenerator:
|
||||
should_set_z_level = False
|
||||
|
||||
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
|
||||
if voided_obj.data:
|
||||
if not preserve_placement and voided_obj.data:
|
||||
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
|
||||
if not raycast[0]:
|
||||
target = filling_obj.matrix_world.translation.copy()
|
||||
@@ -737,6 +760,29 @@ class AddBoolean(Operator, tool.Ifc.Operator):
|
||||
tool.Root.reload_item_decorator()
|
||||
|
||||
|
||||
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.toggle_host_openings"
|
||||
bl_label = "Toggle Openings"
|
||||
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
# Opening visibility is independent of host geometry — don't commit any
|
||||
# active parametric edit; the user can keep editing the host.
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ShowOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.show_openings"
|
||||
bl_label = "Show Openings"
|
||||
@@ -1160,22 +1206,55 @@ class DecorationsHandler:
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
|
||||
# One batch, two draws: front pass at full color, occluded pass at
|
||||
# `occluded_alpha`. Save/restore depth_test matches the pattern in
|
||||
# bim/module/structural/decorator.py so callers' state survives.
|
||||
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if batch is None:
|
||||
def _draw_lines_with_occlusion(self, verts, color, edges_indices, cache_key=None):
|
||||
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
|
||||
#
|
||||
# The solid front pass is rendered WIDER than the dashed back pass so it
|
||||
# produces a halo around the line center, beyond the depth-bias zone that
|
||||
# Blender's overlay engine writes when an opening is set to WIRE display.
|
||||
# Without the width difference, the wire bias makes the center-pixel
|
||||
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
|
||||
# wire depth) so the solid pass would lose to the dashed back pass even
|
||||
# on visible edges. The halo gives the solid pass enough screen-space to
|
||||
# overpower the dashed pattern visually.
|
||||
#
|
||||
# Dashed renders first at the standard width so the solid overlay's wider
|
||||
# halo cleanly hides it on visible edges; on occluded edges the solid
|
||||
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
|
||||
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if front_batch is None:
|
||||
return
|
||||
|
||||
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
|
||||
dash_batch = None
|
||||
if dashed_cache_key is not None:
|
||||
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
|
||||
if dash_batch is None:
|
||||
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
|
||||
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
|
||||
)
|
||||
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
|
||||
if dash_batch is not None and dashed_cache_key is not None:
|
||||
_store_batch_in_cache(dashed_cache_key, dash_batch)
|
||||
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
front_color = list(color)
|
||||
front_color[3] = 1.0
|
||||
self.line_shader.uniform_float("color", front_color)
|
||||
|
||||
if dash_batch is not None:
|
||||
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("ALWAYS")
|
||||
dash_batch.draw(self.line_shader)
|
||||
|
||||
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
self.line_shader.uniform_float("color", color)
|
||||
batch.draw(self.line_shader)
|
||||
gpu.state.depth_test_set("GREATER")
|
||||
dimmed = list(color)
|
||||
dimmed[3] = occluded_alpha
|
||||
self.line_shader.uniform_float("color", dimmed)
|
||||
batch.draw(self.line_shader)
|
||||
front_batch.draw(self.line_shader)
|
||||
|
||||
# Restore the per-iteration default set at the top of __call__ so
|
||||
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
|
||||
# not silently affected by the front-pass width override.
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
gpu.state.depth_test_set(original_depth_test)
|
||||
|
||||
def __call__(self, context):
|
||||
@@ -1211,7 +1290,7 @@ class DecorationsHandler:
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
# POLYLINE_UNIFORM_COLOR specific uniforms
|
||||
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
|
||||
self.line_shader.uniform_float("lineWidth", 2.0)
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
|
||||
# general shader
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
@@ -1249,9 +1328,7 @@ class DecorationsHandler:
|
||||
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
|
||||
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
|
||||
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
|
||||
else:
|
||||
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
|
||||
@@ -163,6 +163,59 @@ def sync_uncommitted_moves(objects: list) -> None:
|
||||
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
|
||||
def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
|
||||
"""Reset a preview PropertyGroup to its idle state on commit / cancel.
|
||||
|
||||
Sets ``is_active`` to False and zeros every ``IntProperty`` whose name
|
||||
ends in ``_id`` (the entity-reference convention every preview follows).
|
||||
Other fields are left at their last value — defaults are re-applied on
|
||||
the next enable, so leaving them alone avoids a redundant write."""
|
||||
props.is_active = False
|
||||
for name, rna in props.bl_rna.properties.items():
|
||||
if name.endswith("_id") and rna.type == "INT":
|
||||
setattr(props, name, 0)
|
||||
|
||||
|
||||
# --- Standard Finish flow ----------------------------------------------------
|
||||
|
||||
|
||||
def commit_preview(
|
||||
operator: bpy.types.Operator,
|
||||
context: bpy.types.Context,
|
||||
attr: str,
|
||||
target_op_name: str,
|
||||
kwarg_names: tuple[str, ...],
|
||||
) -> set[str]:
|
||||
"""Standard Finish-Preview dispatch: validate context + active preview,
|
||||
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
|
||||
and clear the preview on success.
|
||||
|
||||
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
|
||||
by ``bpy.ops`` to ``RuntimeError`` — catching it here surfaces the message
|
||||
to the user via ``operator.report`` rather than leaving Blender's operator
|
||||
state half-broken (which silently disables downstream gizmo polls).
|
||||
|
||||
Returns the dispatched operator's result set verbatim so callers can
|
||||
pass it straight back from their own ``execute``."""
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = get_preview_props(context, attr)
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
if tool.Ifc.get() is None:
|
||||
operator.report({"ERROR"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
kwargs = {name: getattr(props, name) for name in kwarg_names}
|
||||
try:
|
||||
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
|
||||
except RuntimeError as exc:
|
||||
operator.report({"ERROR"}, str(exc))
|
||||
return {"CANCELLED"}
|
||||
if "FINISHED" in result:
|
||||
clear_preview_state(props)
|
||||
return result
|
||||
|
||||
|
||||
# --- Esc dispatch ------------------------------------------------------------
|
||||
|
||||
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
|
||||
|
||||
@@ -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")
|
||||
@@ -1157,7 +1165,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
|
||||
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
|
||||
if connect_IfcFlowSegments:
|
||||
bpy.ops.bim.mep_connect_elements(
|
||||
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
|
||||
obj1_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
|
||||
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
|
||||
)
|
||||
|
||||
def modal(self, context, event):
|
||||
|
||||
@@ -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
|
||||
@@ -103,8 +105,12 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
|
||||
|
||||
|
||||
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
|
||||
bpy.ops.bim.enable_editing_array(item=self.is_editing)
|
||||
return
|
||||
# Skip the cleanup-time clear: Finish/Cancel sets relating_array_object back to None,
|
||||
# which has no source to hydrate from. Only the user-driven pick (None → some array)
|
||||
# should auto-enter edit on the picked source's layer 0.
|
||||
if self.relating_array_object is None:
|
||||
return
|
||||
bpy.ops.bim.enable_editing_array(item=0)
|
||||
|
||||
|
||||
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
|
||||
@@ -128,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)
|
||||
@@ -224,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:
|
||||
@@ -238,6 +253,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
||||
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
|
||||
|
||||
|
||||
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
class BIMModelProperties(PropertyGroup):
|
||||
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
|
||||
relating_type_id: bpy.props.EnumProperty(
|
||||
@@ -340,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")
|
||||
@@ -387,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
|
||||
@@ -397,8 +440,13 @@ class BIMModelProperties(PropertyGroup):
|
||||
|
||||
|
||||
class BIMArrayProperties(PropertyGroup):
|
||||
is_editing: bpy.props.IntProperty(
|
||||
default=-1, description="Currently edited array index. -1 if not in array editing mode."
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while an array layer is in parametric edit mode. The specific layer is in editing_item_index.",
|
||||
)
|
||||
editing_item_index: bpy.props.IntProperty(
|
||||
default=-1,
|
||||
description="Index of the array layer currently being edited; -1 when not in edit mode.",
|
||||
)
|
||||
count: bpy.props.IntProperty(name="Count", default=0, min=0)
|
||||
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
|
||||
@@ -414,6 +462,15 @@ class BIMArrayProperties(PropertyGroup):
|
||||
name="Method",
|
||||
default="OFFSET",
|
||||
)
|
||||
per_child_opening: bpy.props.BoolProperty(
|
||||
name="Per-Child Opening",
|
||||
description=(
|
||||
"When the array parent fills a wall (or any voidable host), give each array child its own opening + "
|
||||
"filling pair so the host is cut once per child. Disable to leave the host uncut by the children — "
|
||||
"only the parent's original opening remains"
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
relating_array_object: bpy.props.PointerProperty(
|
||||
type=bpy.types.Object,
|
||||
name="Copy Array Properties",
|
||||
@@ -422,13 +479,15 @@ class BIMArrayProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: int
|
||||
is_editing: bool
|
||||
editing_item_index: int
|
||||
count: int
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
use_local_space: bool
|
||||
method: Literal["OFFSET", "DISTRIBUTE"]
|
||||
per_child_opening: bool
|
||||
sync_children: bool
|
||||
relating_array_object: Union[bpy.types.Object, None]
|
||||
|
||||
@@ -1659,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.
|
||||
|
||||
@@ -1904,6 +1978,155 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
|
||||
|
||||
|
||||
class BIMPipeSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric pipe-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while pipe-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_pipe_segment,
|
||||
description="Pipe-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMDuctSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric duct-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while duct-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_duct_segment,
|
||||
description="Duct-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMBendPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the bend-creation preview flow.
|
||||
|
||||
Scene-level (not per-object) because the bend involves two segments by
|
||||
IFC id — neither alone owns the draft."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the bend-creation preview flow is active.",
|
||||
)
|
||||
start_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the start (active) segment.",
|
||||
)
|
||||
end_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the end (other selected) segment.",
|
||||
)
|
||||
start_length: bpy.props.FloatProperty(
|
||||
name="Start Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the start (active) segment side",
|
||||
)
|
||||
end_length: bpy.props.FloatProperty(
|
||||
name="End Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the end (other) segment side",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.2,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Inner radius of the bend curve",
|
||||
)
|
||||
editing_bend_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing bend fitting being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this bend + its port connections before "
|
||||
"recreating with the new parameters."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
start_segment_id: int
|
||||
end_segment_id: int
|
||||
start_length: float
|
||||
end_length: float
|
||||
radius: float
|
||||
editing_bend_id: int
|
||||
|
||||
|
||||
class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the wall-fillet preview flow.
|
||||
|
||||
@@ -1960,7 +2183,29 @@ class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
class BIMPreviewProperties(PropertyGroup):
|
||||
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
|
||||
|
||||
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
|
||||
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
bend: BIMBendPreviewProperties
|
||||
wall_fillet: BIMWallFilletPreviewProperties
|
||||
|
||||
|
||||
class BIMParametricEditDialogPrefs(PropertyGroup):
|
||||
"""Session-scoped flag for the parametric-edit pen-icon dispatcher.
|
||||
|
||||
Attached to ``WindowManager`` so the state lives for one Blender session
|
||||
and resets on restart — the right scope for "don't show this again for
|
||||
this session" toggles."""
|
||||
|
||||
suppress_shared_rep_warning: bpy.props.BoolProperty(
|
||||
name="Suppress shared-representation warning",
|
||||
description=(
|
||||
"When true, the pen-icon dispatcher skips the shared-geometry "
|
||||
"confirmation dialog. Resets on Blender restart."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
suppress_shared_rep_warning: bool
|
||||
|
||||
@@ -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,15 +473,14 @@ 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"
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_railing(element)
|
||||
return tool.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -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)
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
from math import cos, pi, radians, tan
|
||||
from typing import Any, Literal, Union
|
||||
from math import atan2, cos, degrees, pi, radians, tan
|
||||
from typing import Any, ClassVar, Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -32,9 +32,11 @@ from mathutils import Quaternion, Vector
|
||||
|
||||
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, IconSlot
|
||||
from bonsai.bim.module.model.data import RoofData, refresh
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
||||
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
|
||||
@@ -211,7 +213,13 @@ def generate_hipped_roof_bmesh(
|
||||
|
||||
new_verts = [bm.verts.new(v) for v in verts]
|
||||
new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges]
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
|
||||
# Skip degenerate faces. ``bpypolyskel.polygonize`` can emit a face whose
|
||||
# vertex list contains the same index twice on certain footprint /
|
||||
# slope combinations (the straight-skeleton collapses two ridge events
|
||||
# onto the same vertex). ``bm.faces.new`` rejects those with
|
||||
# ``found the same (BMVert) used multiple times``; dropping them keeps
|
||||
# the rest of the roof intact instead of aborting the whole rebuild.
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces if len(set(face)) == len(face)]
|
||||
|
||||
if mode == "HEIGHT": # Calculate the angle we ended up with.
|
||||
new_faces[0].normal_update()
|
||||
@@ -397,6 +405,11 @@ def generate_hipped_roof_bmesh(
|
||||
if is_internal:
|
||||
faces_to_delete.add(face)
|
||||
bmesh.ops.delete(bm, geom=list(faces_to_delete), context="FACES")
|
||||
# Final pass: ``remove_doubles`` + internal-face deletion above can leave
|
||||
# the bottom slab faces flipped at low slopes, where the kernel's
|
||||
# "outward" inference becomes ambiguous on near-flat geometry. Recompute
|
||||
# once more on the final topology so the eave plane points down.
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||
return bm
|
||||
|
||||
|
||||
@@ -618,7 +631,7 @@ class _RoofEditMixin(PathPreservingEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_roof(element)
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -636,32 +649,142 @@ class _RoofEditMixin(PathPreservingEditMixin):
|
||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Rebuild the roof bmesh from the just-restored draft props so the
|
||||
viewport reverts to the pre-edit geometry. Same helper the modal
|
||||
edits use, just driven by the cancelled props instead of in-flight
|
||||
drag values."""
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
|
||||
EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
|
||||
"roof",
|
||||
_RoofEditMixin,
|
||||
labels=(
|
||||
("Enable Editing Roof", ""),
|
||||
("Finish Editing Roof", ""),
|
||||
("Cancel Editing Roof", ""),
|
||||
),
|
||||
module_name=__name__,
|
||||
)
|
||||
|
||||
|
||||
# Fixed horizontal run for the slope gizmo: the draggable value is the
|
||||
# vertical rise at this distance from the anchor, in the rise/run convention.
|
||||
_ROOF_SLOPE_REFERENCE_RUN = 1.0
|
||||
# One degree shy of vertical; avoids tan() blow-up when the user drags the
|
||||
# rise handle past the gizmo's anchor.
|
||||
_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
|
||||
|
||||
|
||||
def _roof_has_openings() -> bool:
|
||||
"""``visible_when`` predicate for the toggle_openings idle slot. True iff
|
||||
the active object's IFC element exposes a non-empty HasOpenings inverse."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Geometry.has_openings(element)
|
||||
|
||||
|
||||
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
|
||||
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
|
||||
|
||||
bl_idname = "bim.cycle_roof_generation_method"
|
||||
bl_label = "Cycle Roof Generation Method"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._enable_targets(context)
|
||||
element_checker = tool.Parametric.is_roof
|
||||
props_getter = tool.Model.get_roof_props
|
||||
type_literal = tool.Model.RoofGenerationMethod
|
||||
type_attr = "generation_method"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_idname = "OBJECT_GGT_bim_roof_edition"
|
||||
bl_label = "Roof Editing Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
enable_editing_operator = "bim.enable_editing_roof"
|
||||
finish_editing_operator = "bim.finish_editing_roof"
|
||||
cancel_editing_operator = "bim.cancel_editing_roof"
|
||||
cycle_type_operator = "bim.cycle_roof_generation_method"
|
||||
|
||||
# Positions for all three dimensions are set per-frame by the position
|
||||
# override below; no static ``matrix_position`` is needed.
|
||||
dimension_gizmo_props = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="height",
|
||||
axis=(0, 0, 1),
|
||||
min_value=0.01,
|
||||
visibility_condition=lambda p: p.generation_method == "HEIGHT",
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="angle",
|
||||
axis=(0, 0, 1),
|
||||
prop_name="Slope",
|
||||
min_value=0.0,
|
||||
visibility_condition=lambda p: p.generation_method == "ANGLE",
|
||||
compute_value=lambda p: tan(p.angle) * _ROOF_SLOPE_REFERENCE_RUN,
|
||||
apply_value=lambda p, rise: setattr(
|
||||
p, "angle", min(_ROOF_MAX_SLOPE_ANGLE, max(0.0, atan2(rise, _ROOF_SLOPE_REFERENCE_RUN)))
|
||||
),
|
||||
text_formatter=lambda p, rise: (f"{tool.Unit.format_distance(rise)} ({degrees(p.angle):.1f}°)"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="roof_thickness",
|
||||
axis=(0, 0, -1),
|
||||
min_value=0.001,
|
||||
# The line shows the perpendicular slab thickness (matching the
|
||||
# pset value and the drag delta); the true vertical span is
|
||||
# ``roof_thickness / cos(angle)``, longer than what is drawn.
|
||||
),
|
||||
]
|
||||
|
||||
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
props_getter = tool.Model.get_roof_props
|
||||
gizmo_pref_name = "roof"
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="toggle_openings",
|
||||
gizmo_idname="VIEW3D_GT_add_opening",
|
||||
operator="bim.toggle_host_openings",
|
||||
visible_when=lambda gg: _roof_has_openings(),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
|
||||
"""Anchor every dimension gizmo at the object origin. Each gizmo's
|
||||
declared axis (height/slope along +Z, thickness along -Z) separates
|
||||
them in 3D so they don't visually collide despite sharing a
|
||||
position; the height + slope gizmos themselves are mutually
|
||||
exclusive via ``visibility_condition`` on ``generation_method``."""
|
||||
origin = Vector((0.0, 0.0, 0.0))
|
||||
self.set_dimension_gizmo_position("height", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
|
||||
|
||||
def get_element_height(self, props) -> float: # noqa: ARG002
|
||||
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
|
||||
cancel / cycle row anchors visibly above sloped or stepped roof
|
||||
bodies rather than at the parametric ``props.height`` which may not
|
||||
match the rendered apex on ANGLE-generation roofs."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None or not getattr(obj, "bound_box", None):
|
||||
return 1.0
|
||||
return max(c[2] for c in obj.bound_box)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -31,7 +31,13 @@ from mathutils import Matrix, Vector
|
||||
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.drawing.gizmos import (
|
||||
COLOR_GREEN,
|
||||
COLOR_RED,
|
||||
DimensionGizmoConfig,
|
||||
IconSlot,
|
||||
)
|
||||
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
|
||||
from bonsai.tool.numeric_input import (
|
||||
IntegerInputState,
|
||||
run_integer_input_modal,
|
||||
@@ -39,7 +45,7 @@ from bonsai.tool.numeric_input import (
|
||||
)
|
||||
|
||||
V_ = tool.Blender.V_
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from bmesh.types import BMVert
|
||||
from bpy.props import IntProperty
|
||||
@@ -378,6 +384,20 @@ class AdjustStairTreads(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator):
|
||||
"""Popup-dialog entry point for typing a new ``number_of_treads`` value.
|
||||
Bound to the world-space ``xN`` count label in the stair edit row."""
|
||||
|
||||
bl_idname = "bim.input_stair_treads"
|
||||
bl_label = "Set Number of Treads"
|
||||
bl_description = "Type the number of treads for this stair"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
number_of_treads: IntProperty(name="Number of Treads", default=1, min=1)
|
||||
attr_name = "number_of_treads"
|
||||
props_getter = staticmethod(tool.Model.get_stair_props)
|
||||
|
||||
|
||||
class SetStairTreads(bpy.types.Operator):
|
||||
"""Set the number of treads to a specific value."""
|
||||
|
||||
@@ -423,11 +443,11 @@ class SetStairTreads(bpy.types.Operator):
|
||||
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
|
||||
|
||||
|
||||
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through stair types. Shift+click to cycle in reverse."""
|
||||
class PickStairType(bpy.types.Operator, PickTypeMixin):
|
||||
"""Pick a stair type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_stair_type"
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_idname = "bim.pick_stair_type"
|
||||
bl_label = "Pick Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = tool.Model.get_stair_props
|
||||
@@ -436,7 +456,7 @@ class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
skip_element_check = True
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
|
||||
@@ -462,20 +482,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
# === Stair-Specific Icon Layout (meters) ===
|
||||
# Additional icons for stair editing, positioned after standard icons:
|
||||
# [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
|
||||
ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon
|
||||
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
|
||||
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
|
||||
# === Stair-Specific Icon Layout ===
|
||||
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus]
|
||||
# The base class assigns X positions from ``feature_slots`` tuple order —
|
||||
# adding an icon is a one-line append, no hardcoded X constant.
|
||||
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
|
||||
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
|
||||
ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label
|
||||
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
|
||||
|
||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="tread_lock",
|
||||
gizmo_idname="VIEW3D_GT_lock",
|
||||
variants=("open", "closed"),
|
||||
operator="bim.toggle_stair_property",
|
||||
color=(1.0, 1.0, 1.0),
|
||||
operator_props=(("property_name", "custom_tread_lock"),),
|
||||
),
|
||||
IconSlot(name="tread_count_label", placeholder=True),
|
||||
IconSlot(
|
||||
name="plus",
|
||||
gizmo_idname="VIEW3D_GT_plus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_GREEN,
|
||||
operator_props=(("increment", 1),),
|
||||
),
|
||||
IconSlot(
|
||||
name="minus",
|
||||
gizmo_idname="VIEW3D_GT_minus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_RED,
|
||||
operator_props=(("increment", -1),),
|
||||
),
|
||||
)
|
||||
|
||||
enable_editing_operator = "bim.enable_editing_stair"
|
||||
finish_editing_operator = "bim.finish_editing_stair"
|
||||
cancel_editing_operator = "bim.cancel_editing_stair"
|
||||
cycle_type_operator = "bim.cycle_stair_type"
|
||||
pick_type_operator = "bim.pick_stair_type"
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for stair icon positioning.
|
||||
@@ -585,28 +632,31 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_stair(element)
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create stair-specific icon gizmos (lock, plus, minus)."""
|
||||
self.lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
self.COLOR_BLUE,
|
||||
"""Create the total-length lock as an open/closed pair plus the
|
||||
``xN`` tread-count label. Lock click toggles
|
||||
``props.total_length_lock``; the per-frame update hook picks which
|
||||
member is visible. Anchored to the stair's far X end (not the edit
|
||||
row) so it's positioned by ``_update_lock_gizmo_position`` rather
|
||||
than the toolbar slot system.
|
||||
|
||||
The count label binds to ``bim.input_stair_treads`` (popup dialog)
|
||||
for click-to-type input and sits at the X reserved by the
|
||||
``tread_count_label`` placeholder slot in ``feature_slots``."""
|
||||
self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
|
||||
"bim.toggle_stair_property",
|
||||
self.COLOR_BLUE,
|
||||
property_name="total_length_lock",
|
||||
)
|
||||
self.tread_lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
(1.0, 1.0, 1.0),
|
||||
"bim.toggle_stair_property",
|
||||
property_name="custom_tread_lock",
|
||||
)
|
||||
self.plus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
|
||||
)
|
||||
self.minus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
|
||||
)
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label")
|
||||
self.tread_count_label_gizmo.use_draw_scale = False
|
||||
self.tread_count_label_gizmo.color = default_color
|
||||
self.tread_count_label_gizmo.color_highlight = highlight_color
|
||||
self.tread_count_label_gizmo.alpha = 0.8
|
||||
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -618,30 +668,43 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self.update_tread_count_gizmos(props)
|
||||
|
||||
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Update lock gizmo color and visibility. Positioning is handled
|
||||
per-frame by the dimension-positioning hook."""
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
|
||||
return # Hidden, skip color update
|
||||
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
|
||||
"""Show the open/closed total-length lock variant matching
|
||||
``props.total_length_lock``. Positioning is handled per-frame by
|
||||
the dimension-positioning hook."""
|
||||
if not hasattr(self, "total_length_lock_open_gizmo"):
|
||||
return
|
||||
if not props.is_editing:
|
||||
self.total_length_lock_open_gizmo.hide = True
|
||||
self.total_length_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.total_length_lock_open_gizmo.hide = props.total_length_lock
|
||||
self.total_length_lock_closed_gizmo.hide = not props.total_length_lock
|
||||
|
||||
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "tread_lock_gizmo"):
|
||||
"""Show the open/closed lock variant matching ``props.custom_tread_lock``.
|
||||
|
||||
Both pair members share an X position (set by the base's slot
|
||||
positioning); this picks which one is visible per frame so a state
|
||||
flip can't reveal both at once."""
|
||||
if not hasattr(self, "tread_lock_open_gizmo"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
|
||||
if not props.is_editing:
|
||||
self.tread_lock_open_gizmo.hide = True
|
||||
self.tread_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.tread_lock_open_gizmo.hide = props.custom_tread_lock
|
||||
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
|
||||
|
||||
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
|
||||
"""Update visibility of the +/- tread count gizmos and the ``xN``
|
||||
label. Positioning is handled in ``_update_editing_icon_positions``."""
|
||||
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
|
||||
# Minus has additional condition: number_of_treads > 1
|
||||
self.update_gizmo_visibility(
|
||||
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
|
||||
)
|
||||
self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -719,10 +782,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
billboard_rot: Matrix,
|
||||
total_run: float,
|
||||
) -> None:
|
||||
"""Update lock gizmo position based on Y view direction."""
|
||||
"""Update lock gizmo pair position based on Y view direction. Writes
|
||||
the matrix on both members so a state flip can't reveal a stale pose."""
|
||||
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
|
||||
self.set_icon_gizmo_position(
|
||||
"lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"total_length_lock_open_gizmo",
|
||||
"total_length_lock_closed_gizmo",
|
||||
mw,
|
||||
total_run + self.ICON_Z_OFFSET,
|
||||
y_pos,
|
||||
@@ -734,30 +799,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
def _update_editing_icon_positions(
|
||||
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix
|
||||
) -> None:
|
||||
"""Update editing icon positions, flipping Y based on viewing angle."""
|
||||
"""Reposition the editing icons at stair's view-dependent Y. The base
|
||||
class's update_editing_gizmos already placed them at the default
|
||||
``get_icon_y_offset`` Y — this overrides with the stair-specific
|
||||
``get_icon_y_for_view`` flip so the icons land on the side the
|
||||
camera is looking from."""
|
||||
if not props.is_editing:
|
||||
return
|
||||
|
||||
icon_z = props.height + self.ICON_Z_OFFSET
|
||||
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
|
||||
slot_x = self._slot_x_positions()
|
||||
|
||||
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position(
|
||||
"cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"tread_lock_open_gizmo",
|
||||
"tread_lock_closed_gizmo",
|
||||
mw,
|
||||
self.ICON_TREAD_LOCK_X,
|
||||
slot_x["tread_lock"],
|
||||
y_pos,
|
||||
icon_z - self.EDITING_ICON_SCALE / 2,
|
||||
billboard_rot,
|
||||
scale=self.EDITING_ICON_SCALE,
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.tread_count_label_gizmo.set_count(int(props.number_of_treads))
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_count_label_gizmo",
|
||||
mw,
|
||||
slot_x["tread_count_label"],
|
||||
y_pos,
|
||||
icon_z,
|
||||
billboard_rot,
|
||||
scale=self.ICON_COUNT_LABEL_SCALE,
|
||||
)
|
||||
|
||||
@@ -238,11 +238,11 @@ class BIM_PT_array(bpy.types.Panel):
|
||||
|
||||
for i, array in enumerate(ArrayData.data["parameters"]["data_dict"]):
|
||||
box = self.layout.box()
|
||||
if props.is_editing == i:
|
||||
if props.editing_item_index == i:
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "count", icon="MOD_ARRAY")
|
||||
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
|
||||
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
|
||||
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
|
||||
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "method")
|
||||
row = box.row(align=True)
|
||||
@@ -365,7 +365,7 @@ class BIM_PT_wall(bpy.types.Panel):
|
||||
if not obj:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return bool(element) and tool.Blender.Modifier.is_wall(element)
|
||||
return bool(element) and tool.Parametric.is_wall(element)
|
||||
|
||||
def draw(self, context):
|
||||
obj = context.active_object
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
# 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.
|
||||
|
||||
"""Four wall-offset dimension gizmos (left / right / top / bottom) shared by door and
|
||||
window edit gizmo groups — both fillings sit in a LAYER2 wall and the offset math is
|
||||
identical.
|
||||
|
||||
The compute side returns a *signed* value on the X axis (negative when the filling
|
||||
is 180°-flipped onto the wall's opposite face) so the gizmo framework auto-flips
|
||||
the rendered arrow; the apply side takes ``abs(value)`` because the user-facing
|
||||
offset is always positive. Z-axis values are unsigned in both directions.
|
||||
|
||||
Fillings are assumed to align with the wall's local X axis to within ±90° — the
|
||||
parametric door/window construction path enforces this, and the X-sign math
|
||||
falls back to +1 if ``col[0].x`` lands on the ambiguous zero (filling rotated
|
||||
exactly 90° in the wall plane).
|
||||
|
||||
Every public entry point falls back to a safe no-op when the host-wall chain
|
||||
cannot be resolved: reads return 0.0, writes do nothing, and gizmo anchors
|
||||
return a filling-relative position. This keeps the gizmos non-crashing when a
|
||||
filling momentarily loses its host (e.g. mid-edit, partially-loaded files).
|
||||
|
||||
``_GEOM_CACHE`` is module-scoped and persists across tests — tests must call
|
||||
``clear_caches()`` between cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, NamedTuple, Protocol
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
|
||||
class FillingProps(Protocol):
|
||||
"""Structural subset of door/window props this module touches."""
|
||||
|
||||
id_data: bpy.types.Object
|
||||
overall_width: float
|
||||
overall_height: float
|
||||
|
||||
|
||||
# Wall-local frame axis indices. Y (depth) is unused — fillings sit on the wall's centreline.
|
||||
_AXIS_X = 0
|
||||
_AXIS_Z = 2
|
||||
|
||||
|
||||
class _HostWallGeom(NamedTuple):
|
||||
"""Cached host-wall geometry in SI metres, wall-local frame. ``height`` is
|
||||
the vertical projection (already accounts for slanted extrusions)."""
|
||||
|
||||
wall_obj: bpy.types.Object
|
||||
height: float
|
||||
axis_min_x: float
|
||||
axis_max_x: float
|
||||
|
||||
|
||||
class _AxisExtent(NamedTuple):
|
||||
"""``[low, high]`` interval on one wall-local axis; low = near end.
|
||||
|
||||
``x_sign`` is +1 / -1 for an X-axis filling extent only (carries the
|
||||
180° auto-flip); always 1.0 elsewhere."""
|
||||
|
||||
low: float
|
||||
high: float
|
||||
x_sign: float = 1.0
|
||||
|
||||
|
||||
class _Edge(NamedTuple):
|
||||
"""Wall edge a gizmo measures to. ``is_max_end=True`` picks right/top, else left/bottom."""
|
||||
|
||||
axis_index: int
|
||||
is_max_end: bool
|
||||
|
||||
|
||||
_LEFT = _Edge(axis_index=_AXIS_X, is_max_end=False)
|
||||
_RIGHT = _Edge(axis_index=_AXIS_X, is_max_end=True)
|
||||
_BOTTOM = _Edge(axis_index=_AXIS_Z, is_max_end=False)
|
||||
_TOP = _Edge(axis_index=_AXIS_Z, is_max_end=True)
|
||||
|
||||
|
||||
# Avoids repeating the host-wall chain walk + LAYER2 geometry read per gizmo per frame.
|
||||
_GEOM_CACHE = tool.Parametric.GenerationKeyedCache()
|
||||
|
||||
|
||||
def clear_caches() -> None:
|
||||
_GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
"""Cached host-wall geometry for a filling, or ``None`` if any link in
|
||||
filling → opening → wall → LAYER2 extrusion → scene-object resolution breaks."""
|
||||
return _GEOM_CACHE.get_or_compute(filling_obj.name, lambda: _compute_host_wall_geom(filling_obj))
|
||||
|
||||
|
||||
def _compute_host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
element = tool.Ifc.get_entity(filling_obj)
|
||||
if not element:
|
||||
return None
|
||||
host_wall = tool.Spatial.get_host_wall(element)
|
||||
if not host_wall:
|
||||
return None
|
||||
wall_obj = tool.Ifc.get_object(host_wall)
|
||||
length_height = tool.Wall.get_length_and_height(host_wall)
|
||||
axis_extent = tool.Wall.get_axis_local_extent(host_wall)
|
||||
# x_angle is None for non-LAYER2 walls — gates entry; the value itself is unused.
|
||||
if not (wall_obj and length_height and axis_extent and tool.Wall.get_x_angle(host_wall) is not None):
|
||||
return None
|
||||
_, height = length_height
|
||||
axis_min_x, axis_max_x = axis_extent
|
||||
return _HostWallGeom(wall_obj=wall_obj, height=height, axis_min_x=axis_min_x, axis_max_x=axis_max_x)
|
||||
|
||||
|
||||
def _filling_axis_extent(props: FillingProps, host_wall_obj: bpy.types.Object, axis_index: int) -> _AxisExtent:
|
||||
"""Filling footprint on the wall's local axis.
|
||||
|
||||
X-axis extent carries the filling's orientation sign (180° flip onto
|
||||
the opposite face) in ``x_sign``."""
|
||||
filling_in_wall = host_wall_obj.matrix_world.inverted() @ props.id_data.matrix_world
|
||||
origin = filling_in_wall.translation[axis_index]
|
||||
if axis_index == _AXIS_X:
|
||||
# col[0].x is the X-component of the filling's local X axis in the wall-local frame:
|
||||
# +1 when filling's +X aligns with wall's +X, -1 after a 180° Z-flip.
|
||||
x_sign = 1.0 if filling_in_wall.col[0].x >= 0.0 else -1.0
|
||||
signed_width = x_sign * props.overall_width
|
||||
return _AxisExtent(origin + min(0.0, signed_width), origin + max(0.0, signed_width), x_sign)
|
||||
return _AxisExtent(origin, origin + props.overall_height)
|
||||
|
||||
|
||||
def _wall_axis_extent(geom: _HostWallGeom, axis_index: int) -> _AxisExtent:
|
||||
"""Wall span on one local axis: X = IFC axis-line endpoints (not mesh bound-box,
|
||||
which drifts on trimmed walls); Z = 0 → wall height."""
|
||||
if axis_index == _AXIS_X:
|
||||
return _AxisExtent(geom.axis_min_x, geom.axis_max_x)
|
||||
return _AxisExtent(0.0, geom.height)
|
||||
|
||||
|
||||
def _offset_from_extents(filling: _AxisExtent, wall: _AxisExtent, is_max_end: bool) -> float:
|
||||
"""Distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
if is_max_end:
|
||||
return wall.high - filling.high
|
||||
return filling.low - wall.low
|
||||
|
||||
|
||||
def _translate_along_wall_axis(
|
||||
props: FillingProps, host_wall_obj: bpy.types.Object, delta: float, axis_index: int
|
||||
) -> None:
|
||||
"""Shift the filling by ``delta`` SI metres along the wall's local axis. Drag
|
||||
operates in the filling's intent frame, not Blender's world frame, so a rotated
|
||||
host wall still tracks correctly."""
|
||||
if delta == 0.0:
|
||||
return
|
||||
direction_world = host_wall_obj.matrix_world.to_3x3().col[axis_index].normalized()
|
||||
props.id_data.matrix_world.translation = props.id_data.matrix_world.translation + direction_world * delta
|
||||
|
||||
|
||||
def _get_offset(props: FillingProps, edge: _Edge) -> float:
|
||||
"""SI distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _set_offset(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Translate the filling so its offset to ``edge`` becomes ``max(0, value)`` SI metres.
|
||||
Max-end edges (right/top) translate in the opposite direction of near-end edges."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return
|
||||
current = _get_offset(props, edge)
|
||||
target = max(0.0, value)
|
||||
delta = (current - target) if edge.is_max_end else (target - current)
|
||||
_translate_along_wall_axis(props, geom.wall_obj, delta, edge.axis_index)
|
||||
|
||||
|
||||
def has_host_wall(props: FillingProps) -> bool:
|
||||
"""True when the filling resolves to a LAYER2 host wall present in the scene."""
|
||||
return _host_wall_geom(props.id_data) is not None
|
||||
|
||||
|
||||
def _edge_position(props: FillingProps, edge: _Edge) -> Vector:
|
||||
"""Gizmo anchor in filling-local space, at the wall edge, pointing toward the filling."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
if edge.axis_index == _AXIS_X:
|
||||
return Vector((0.0, 0.0, props.overall_height / 2))
|
||||
return Vector((props.overall_width / 2, 0.0, props.overall_height if edge.is_max_end else 0.0))
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
edge_value = wall.high if edge.is_max_end else wall.low
|
||||
if edge.axis_index == _AXIS_X:
|
||||
wall_edge_world = geom.wall_obj.matrix_world @ Vector((edge_value, 0.0, 0.0))
|
||||
pos = props.id_data.matrix_world.inverted() @ wall_edge_world
|
||||
return Vector((pos.x, 0.0, props.overall_height / 2))
|
||||
# LAYER2 wall matrix_world is upright, so wall-local Z and filling-local Z differ
|
||||
# only by the filling's Z origin in the wall frame.
|
||||
filling_z_in_wall = _filling_axis_extent(props, geom.wall_obj, axis_index=_AXIS_Z).low
|
||||
return Vector((props.overall_width / 2, 0.0, edge_value - filling_z_in_wall))
|
||||
|
||||
|
||||
def _compute_value(props: FillingProps, edge: _Edge) -> float:
|
||||
"""Renderer-side value. X-axis edges return a signed value so the gizmo's
|
||||
auto-flip kicks in for fillings on the wall's opposite face; Z-axis returns unsigned."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return filling.x_sign * _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _apply_value(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Drag-end commit; X-axis takes ``abs(value)`` since the negative sign in compute
|
||||
is a rendering hint only (user-facing offset is always positive)."""
|
||||
if edge.axis_index == _AXIS_X:
|
||||
_set_offset(props, edge, abs(value))
|
||||
else:
|
||||
_set_offset(props, edge, value)
|
||||
|
||||
|
||||
# attr_name identifies the gizmo within its group; values flow through
|
||||
# compute/apply, not via a registered property.
|
||||
WALL_OFFSET_GIZMO_CONFIGS: list[DimensionGizmoConfig] = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_left",
|
||||
axis=(1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _LEFT),
|
||||
apply_value=lambda p, v: _apply_value(p, _LEFT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _LEFT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_right",
|
||||
axis=(-1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _RIGHT),
|
||||
apply_value=lambda p, v: _apply_value(p, _RIGHT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _RIGHT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_bottom",
|
||||
axis=(0, 0, 1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _BOTTOM),
|
||||
apply_value=lambda p, v: _apply_value(p, _BOTTOM, v),
|
||||
matrix_position=lambda p: _edge_position(p, _BOTTOM),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_top",
|
||||
axis=(0, 0, -1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _TOP),
|
||||
apply_value=lambda p, v: _apply_value(p, _TOP, v),
|
||||
matrix_position=lambda p: _edge_position(p, _TOP),
|
||||
),
|
||||
]
|
||||
@@ -39,7 +39,8 @@ 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.parametric_lifecycle import FeatureModifierEditMixin
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -491,7 +492,7 @@ class _WindowEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -551,11 +552,11 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available window types. Shift+click to cycle in reverse."""
|
||||
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a window type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_window_type"
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_idname = "bim.pick_window_type"
|
||||
bl_label = "Pick Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_window
|
||||
@@ -564,7 +565,7 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixi
|
||||
type_attr = "window_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
|
||||
@@ -602,7 +603,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_window"
|
||||
finish_editing_operator = "bim.finish_editing_window"
|
||||
cancel_editing_operator = "bim.cancel_editing_window"
|
||||
cycle_type_operator = "bim.cycle_window_type"
|
||||
pick_type_operator = "bim.pick_window_type"
|
||||
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
dimension_gizmo_props = [
|
||||
@@ -743,6 +744,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
),
|
||||
# lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support
|
||||
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_window_props
|
||||
@@ -750,7 +752,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for window icon positioning.
|
||||
|
||||
@@ -963,13 +963,19 @@ 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)
|
||||
|
||||
if PortData.data["total_ports"] > 0:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
|
||||
add_layout_hotkey_operator(
|
||||
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def draw_void(cls, context, row):
|
||||
@@ -1315,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):
|
||||
@@ -1442,10 +1448,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.enable_editing_extrusion_axis()
|
||||
|
||||
def hotkey_A_O(self):
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
bpy.ops.bim.toggle_host_openings()
|
||||
|
||||
def hotkey_C_E(self):
|
||||
if not bpy.context.selected_objects:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ classes = (
|
||||
operator.AppendLibraryElementByQuery,
|
||||
operator.AssignLibraryDeclaration,
|
||||
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,
|
||||
operator.ChangeLibraryElement,
|
||||
@@ -82,6 +88,8 @@ classes = (
|
||||
prop.FilterCategory,
|
||||
prop.Link,
|
||||
prop.EditedObj,
|
||||
prop.PendingArrayRepair,
|
||||
prop.PendingOpeningRecut,
|
||||
prop.BIMProjectProperties,
|
||||
prop.MeasureToolSettings,
|
||||
ui.BIM_MT_new_project,
|
||||
|
||||
@@ -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"))
|
||||
@@ -1223,6 +1237,30 @@ class LoadProjectElements(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
props.is_loading = False
|
||||
|
||||
# Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
|
||||
# The Project panel banner offers the user a one-click recut.
|
||||
props.pending_opening_recut.clear()
|
||||
if ifc_importer.gross_elements:
|
||||
for element in ifc_importer.gross_elements:
|
||||
item = props.pending_opening_recut.add()
|
||||
item.ifc_definition_id = element.id()
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
|
||||
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()
|
||||
@@ -3421,3 +3459,149 @@ class GenerateUVMap(bpy.types.Operator):
|
||||
tool.Loader.load_generated_uv_map(obj.data)
|
||||
self.report({"INFO"}, "Generated UV map for selected mesh.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Recompute the wall mesh including opening subtractions for every host
|
||||
that the load-time ``void_limit`` filter skipped. Clears the deferred
|
||||
list on completion so the panel banner disappears."""
|
||||
|
||||
bl_idname = "bim.apply_pending_opening_cuts"
|
||||
bl_label = "Apply Pending Opening Cuts"
|
||||
bl_description = (
|
||||
"Recompute meshes for elements whose openings were skipped at load because they had too many openings"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
pending = tool.Project.get_project_props().pending_opening_recut
|
||||
applied = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
for item in pending:
|
||||
try:
|
||||
element = tool.Ifc.get().by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
skipped += 1
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
skipped += 1
|
||||
continue
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if body is None:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
|
||||
applied += 1
|
||||
except (RuntimeError, OSError, AttributeError) as exc:
|
||||
# Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
|
||||
failed += 1
|
||||
print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
|
||||
|
||||
pending.clear()
|
||||
message = f"Applied opening cuts to {applied} element(s)."
|
||||
if skipped:
|
||||
message += f" {skipped} entry/entries skipped (entity or object no longer available)."
|
||||
if failed:
|
||||
message += f" {failed} entry/entries failed (see system console)."
|
||||
self.report({"WARNING"}, message)
|
||||
else:
|
||||
self.report({"INFO"}, message)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_pending_opening_cuts"
|
||||
bl_label = "Dismiss Pending Opening Cuts"
|
||||
bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
tool.Project.get_project_props().pending_opening_recut.clear()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_multi_instance_warning"
|
||||
bl_label = "Dismiss Multi-Instance Warning"
|
||||
bl_description = (
|
||||
"Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
|
||||
)
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
from bonsai.bim.ifc import dismiss_multi_instance_warning
|
||||
|
||||
dismiss_multi_instance_warning()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.select_pending_opening_cuts"
|
||||
bl_label = "Select Elements With Skipped Opening Cuts"
|
||||
bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
|
||||
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_opening_recut:
|
||||
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)} 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
|
||||
|
||||
@@ -295,6 +296,28 @@ class LibraryBreadcrumb(PropertyGroup):
|
||||
library_id: int
|
||||
|
||||
|
||||
class PendingOpeningRecut(PropertyGroup):
|
||||
"""One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
|
||||
and was imported without opening subtractions. The user can later apply
|
||||
them on demand from the Project panel banner."""
|
||||
|
||||
ifc_definition_id: IntProperty(name="IFC Definition ID")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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)
|
||||
@@ -360,6 +383,8 @@ class BIMProjectProperties(PropertyGroup):
|
||||
default=30,
|
||||
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,
|
||||
@@ -525,6 +550,8 @@ class BIMProjectProperties(PropertyGroup):
|
||||
deflection_tolerance: float
|
||||
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"]
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import gpu
|
||||
from bpy.app.handlers import persistent
|
||||
@@ -78,15 +79,9 @@ class SystemDecorator:
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""mutates original bm (triangulates it)
|
||||
so the triangulation edges will be shown too
|
||||
"""
|
||||
traingulated_bm = bm
|
||||
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
|
||||
|
||||
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
|
||||
|
||||
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
@@ -317,13 +317,23 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Connect MEP Elements"
|
||||
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
obj1_name: bpy.props.StringProperty(name="Object 1")
|
||||
obj2_name: bpy.props.StringProperty(name="Object 2")
|
||||
obj1_guid: bpy.props.StringProperty(name="Object 1 GlobalId")
|
||||
obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId")
|
||||
|
||||
def _execute(self, context):
|
||||
if self.obj1_name and self.obj2_name:
|
||||
obj1 = bpy.data.objects.get(self.obj1_name)
|
||||
obj2 = bpy.data.objects.get(self.obj2_name)
|
||||
if self.obj1_guid and self.obj2_guid:
|
||||
ifc_file = tool.Ifc.get()
|
||||
try:
|
||||
el1_lookup = ifc_file.by_guid(self.obj1_guid)
|
||||
el2_lookup = ifc_file.by_guid(self.obj2_guid)
|
||||
except RuntimeError:
|
||||
self.report({"ERROR"}, "Could not resolve MEP elements from supplied GlobalIds.")
|
||||
return {"CANCELLED"}
|
||||
obj1 = tool.Ifc.get_object(el1_lookup)
|
||||
obj2 = tool.Ifc.get_object(el2_lookup)
|
||||
if not obj1 or not obj2:
|
||||
self.report({"ERROR"}, "Supplied MEP elements have no Blender object bound.")
|
||||
return {"CANCELLED"}
|
||||
else:
|
||||
if not context.selected_objects or len(context.selected_objects) != 2:
|
||||
self.report({"ERROR"}, "Need to select 2 objects.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,9 +36,17 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_description = (
|
||||
"Apply opening objects to an Element.\n\n"
|
||||
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
|
||||
"Opening can be just a Blender mesh object."
|
||||
"Opening can be just a Blender mesh object.\n\n"
|
||||
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
|
||||
"and the rl1/rl2 Z-elevation default that the regular click applies."
|
||||
)
|
||||
|
||||
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
|
||||
# click. The filling-opening generator gates its snap-to-wall-axis block
|
||||
# on this flag. HIDDEN + SKIP_SAVE so the flag doesn't surface in the F6
|
||||
# redo panel or persist into saved keymaps.
|
||||
preserve_placement: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if len(context.selected_objects) < 2:
|
||||
@@ -46,6 +54,10 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return False
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.preserve_placement = bool(event.shift)
|
||||
return self.execute(context)
|
||||
|
||||
def _execute(self, context):
|
||||
selected_objects = context.selected_objects
|
||||
target_object = selected_objects[0]
|
||||
@@ -68,7 +80,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
|
||||
obj1, obj2 = obj2, obj1
|
||||
FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation)
|
||||
FilledOpeningGenerator().generate(
|
||||
obj2,
|
||||
obj1,
|
||||
target=obj2.matrix_world.translation,
|
||||
preserve_placement=self.preserve_placement,
|
||||
)
|
||||
continue
|
||||
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.
|
||||
|
||||
@@ -61,7 +61,7 @@ Pattern selection (which approach a new feature should adopt):
|
||||
The authoritative list of registered parametric types — and which use
|
||||
`build_edit_lifecycle` vs. standalone operators — lives in
|
||||
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
|
||||
contract tests in `test/bim/test_parametric_registry.py`.
|
||||
contract tests.
|
||||
|
||||
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
|
||||
The lightweight parametric registry consumed at addon-enable time must stay
|
||||
@@ -528,6 +528,51 @@ class PickTypeMixin(TypeAccessorBase):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class IntegerInputDialogMixin:
|
||||
"""Operator mixin that mirrors a per-feature ``IntProperty`` on the
|
||||
operator into a draft attribute on the active object's parametric props,
|
||||
via Blender's ``invoke_props_dialog`` popup.
|
||||
|
||||
Subclasses declare:
|
||||
|
||||
- ``attr_name`` — name of the IntProperty on the subclass AND of the
|
||||
attribute on the resolved props (same name on both sides).
|
||||
- ``props_getter`` — ``staticmethod(tool.Model.get_<feature>_props)``.
|
||||
- ``requires_editing`` — True iff the operator must no-op outside an
|
||||
active edit lifecycle. Default False.
|
||||
- ``value_min`` — minimum value to clamp to. Default 1."""
|
||||
|
||||
attr_name: ClassVar[str] = ""
|
||||
props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]]
|
||||
requires_editing: ClassVar[bool] = False
|
||||
value_min: ClassVar[int] = 1
|
||||
|
||||
def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None:
|
||||
"""Return the active object's parametric props if the operator is
|
||||
allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``)."""
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return None
|
||||
props = self.props_getter(obj)
|
||||
if self.requires_editing and not props.is_editing:
|
||||
return None
|
||||
return props
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
|
||||
props = self._resolve_props(context)
|
||||
if props is None:
|
||||
return {"CANCELLED"}
|
||||
setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name)))
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
props = self._resolve_props(context)
|
||||
if props is None:
|
||||
return {"CANCELLED"}
|
||||
setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name)))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --- Undo-resync registry ----------------------------------------------------
|
||||
#
|
||||
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
|
||||
|
||||
+100
-239
@@ -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,
|
||||
@@ -275,202 +276,37 @@ class BIM_UL_panel_visibilities(bpy.types.UIList):
|
||||
row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False)
|
||||
|
||||
|
||||
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
|
||||
"""Property group for door gizmo visibility settings."""
|
||||
|
||||
overall_height: BoolProperty(name="Overall Height", default=True)
|
||||
overall_width: BoolProperty(name="Overall Width", default=True)
|
||||
threshold_thickness: BoolProperty(name="Threshold Thickness", default=True)
|
||||
threshold_depth: BoolProperty(name="Threshold Depth", default=True)
|
||||
threshold_offset: BoolProperty(name="Threshold Offset", default=True)
|
||||
lining_offset: BoolProperty(name="Lining Offset", default=True)
|
||||
lining_depth: BoolProperty(name="Lining Depth", default=True)
|
||||
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
|
||||
transom_offset: BoolProperty(name="Transom Offset", default=True)
|
||||
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
|
||||
casing_thickness: BoolProperty(name="Casing Thickness", default=True)
|
||||
casing_depth: BoolProperty(name="Casing Depth", default=True)
|
||||
swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc")
|
||||
flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
overall_height: bool
|
||||
overall_width: bool
|
||||
threshold_thickness: bool
|
||||
threshold_depth: bool
|
||||
threshold_offset: bool
|
||||
lining_offset: bool
|
||||
lining_depth: bool
|
||||
lining_thickness: bool
|
||||
transom_offset: bool
|
||||
transom_thickness: bool
|
||||
casing_thickness: bool
|
||||
casing_depth: bool
|
||||
swing_arc: bool
|
||||
flip_arc: bool
|
||||
|
||||
|
||||
class GizmoPreferencesWindow(bpy.types.PropertyGroup):
|
||||
"""Property group for window gizmo visibility settings."""
|
||||
|
||||
overall_height: BoolProperty(name="Overall Height", default=True)
|
||||
overall_width: BoolProperty(name="Overall Width", default=True)
|
||||
lining_offset: BoolProperty(name="Lining Offset", default=True)
|
||||
lining_depth: BoolProperty(name="Lining Depth", default=True)
|
||||
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
|
||||
lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True)
|
||||
lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True)
|
||||
frame_depth: BoolProperty(name="Frame Depth", default=True)
|
||||
frame_thickness: BoolProperty(name="Frame Thickness", default=True)
|
||||
mullion_thickness: BoolProperty(name="Mullion Thickness", default=True)
|
||||
first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True)
|
||||
second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True)
|
||||
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
|
||||
first_transom_offset: BoolProperty(name="First Transom Offset", default=True)
|
||||
second_transom_offset: BoolProperty(name="Second Transom Offset", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
overall_height: bool
|
||||
overall_width: bool
|
||||
lining_offset: bool
|
||||
lining_depth: bool
|
||||
lining_thickness: bool
|
||||
lining_to_panel_offset_x: bool
|
||||
lining_to_panel_offset_y: bool
|
||||
frame_depth: bool
|
||||
frame_thickness: bool
|
||||
mullion_thickness: bool
|
||||
first_mullion_offset: bool
|
||||
second_mullion_offset: bool
|
||||
transom_thickness: bool
|
||||
first_transom_offset: bool
|
||||
second_transom_offset: bool
|
||||
|
||||
|
||||
class GizmoPreferencesStair(bpy.types.PropertyGroup):
|
||||
"""Property group for stair gizmo visibility settings."""
|
||||
|
||||
width: BoolProperty(name="Width", default=True)
|
||||
height: BoolProperty(name="Height", default=True)
|
||||
tread_run: BoolProperty(name="Tread Run", default=True)
|
||||
tread_depth: BoolProperty(name="Tread Depth", default=True)
|
||||
riser_height: BoolProperty(name="Riser Height", default=True)
|
||||
nosing_length: BoolProperty(name="Nosing Length", default=True)
|
||||
nosing_depth: BoolProperty(name="Nosing Depth", default=True)
|
||||
total_length_target: BoolProperty(name="Total Length Target", default=True)
|
||||
base_slab_depth: BoolProperty(name="Base Slab Depth", default=True)
|
||||
top_slab_depth: BoolProperty(name="Top Slab Depth", default=True)
|
||||
lock: BoolProperty(name="Total Length Lock", default=True)
|
||||
plus: BoolProperty(name="Add Tread (+)", default=True)
|
||||
minus: BoolProperty(name="Remove Tread (-)", default=True)
|
||||
cycle: BoolProperty(name="Cycle Stair Type", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
width: bool
|
||||
height: bool
|
||||
tread_run: bool
|
||||
tread_depth: bool
|
||||
riser_height: bool
|
||||
nosing_length: bool
|
||||
nosing_depth: bool
|
||||
total_length_target: bool
|
||||
base_slab_depth: bool
|
||||
top_slab_depth: bool
|
||||
lock: bool
|
||||
plus: bool
|
||||
minus: bool
|
||||
cycle: bool
|
||||
|
||||
|
||||
class GizmoPreferencesWall(bpy.types.PropertyGroup):
|
||||
"""Property group for wall gizmo visibility settings."""
|
||||
|
||||
length: BoolProperty(
|
||||
name="Length",
|
||||
default=True,
|
||||
description="Show the length dimension gizmo along the wall axis.",
|
||||
)
|
||||
height: BoolProperty(
|
||||
name="Height",
|
||||
default=True,
|
||||
description="Show the height dimension gizmo at the wall's start endpoint.",
|
||||
)
|
||||
height_end: BoolProperty(
|
||||
name="Height (far end, walls > 5m)",
|
||||
default=True,
|
||||
description=(
|
||||
"Show a second height gizmo at the wall's far end so long walls don't "
|
||||
"require panning to reach the handle."
|
||||
),
|
||||
)
|
||||
x_angle: BoolProperty(
|
||||
name="Slope",
|
||||
default=True,
|
||||
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
|
||||
)
|
||||
cycle: BoolProperty(
|
||||
name="Cycle Offset Baseline",
|
||||
default=True,
|
||||
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
|
||||
)
|
||||
scissors: BoolProperty(
|
||||
name="Split at cursor",
|
||||
default=True,
|
||||
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
|
||||
)
|
||||
extend: BoolProperty(
|
||||
name="Extend length to cursor X",
|
||||
default=True,
|
||||
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
|
||||
)
|
||||
extend_height: BoolProperty(
|
||||
name="Extend height to cursor Z",
|
||||
default=True,
|
||||
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
|
||||
)
|
||||
rotate: BoolProperty(
|
||||
name="Rotate 90°",
|
||||
default=True,
|
||||
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
|
||||
)
|
||||
toggle_openings: BoolProperty(
|
||||
name="Toggle Openings",
|
||||
default=True,
|
||||
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
length: bool
|
||||
height: bool
|
||||
height_end: bool
|
||||
x_angle: bool
|
||||
cycle: bool
|
||||
scissors: bool
|
||||
extend: bool
|
||||
extend_height: bool
|
||||
rotate: bool
|
||||
toggle_openings: bool
|
||||
|
||||
|
||||
class GizmoPreferences(bpy.types.PropertyGroup):
|
||||
"""Property group for all gizmo visibility settings."""
|
||||
"""Aggregator for parametric gizmo visibility settings. One flat bool per
|
||||
parametric feature; controls whether that feature's gizmo group polls
|
||||
visible in the viewport."""
|
||||
|
||||
draw_gizmos_in_3d_viewport: BoolProperty(
|
||||
name="Draw Gizmos In 3D Viewport",
|
||||
default=True,
|
||||
description="Show interactive gizmos in the 3D viewport for parametric elements",
|
||||
)
|
||||
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
|
||||
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
|
||||
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
|
||||
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
|
||||
door: BoolProperty(name="Door", default=True)
|
||||
window: BoolProperty(name="Window", default=True)
|
||||
stair: BoolProperty(name="Stair", default=True)
|
||||
railing: BoolProperty(name="Railing", default=True)
|
||||
roof: BoolProperty(name="Roof", default=True)
|
||||
array: BoolProperty(name="Array", default=True)
|
||||
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
|
||||
duct_segment: BoolProperty(name="Duct Segment", default=True)
|
||||
wall: BoolProperty(name="Wall", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
draw_gizmos_in_3d_viewport: bool
|
||||
door: GizmoPreferencesDoor
|
||||
window: GizmoPreferencesWindow
|
||||
stair: GizmoPreferencesStair
|
||||
wall: GizmoPreferencesWall
|
||||
door: bool
|
||||
window: bool
|
||||
stair: bool
|
||||
railing: bool
|
||||
roof: bool
|
||||
array: bool
|
||||
pipe_segment: bool
|
||||
duct_segment: bool
|
||||
wall: bool
|
||||
|
||||
|
||||
class DocPreferences(bpy.types.PropertyGroup):
|
||||
@@ -682,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",
|
||||
@@ -918,61 +763,13 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
)
|
||||
|
||||
def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
"""Render one enabled-toggle per parametric feature."""
|
||||
layout.label(text="Toggle visibility of gizmos in editing mode")
|
||||
box = layout.box()
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
|
||||
|
||||
def _draw_parametric_gizmo_parameters(
|
||||
self,
|
||||
layout: bpy.types.UILayout,
|
||||
gizmo_pg: bpy.types.PropertyGroup,
|
||||
dimension_gizmo_class: type,
|
||||
special_gizmo_names: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
|
||||
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
|
||||
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
|
||||
like baseline cycle, scissors, rotate, …)."""
|
||||
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
|
||||
try:
|
||||
annotations = gizmo_pg.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(gizmo_pg).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in visible_names:
|
||||
layout.prop(gizmo_pg, prop)
|
||||
|
||||
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
|
||||
)
|
||||
|
||||
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.window import GizmoWindowEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
|
||||
|
||||
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
|
||||
)
|
||||
|
||||
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout,
|
||||
self.gizmos.wall,
|
||||
GizmoWallEdition,
|
||||
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
|
||||
)
|
||||
annotations = type(self.gizmos).__annotations__
|
||||
for feature in tool.Parametric.EDIT_TYPES:
|
||||
if feature.name in annotations:
|
||||
box.prop(self.gizmos, feature.name)
|
||||
|
||||
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "occurrence_name_style")
|
||||
@@ -1019,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()
|
||||
@@ -1169,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"):
|
||||
@@ -2120,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()
|
||||
@@ -2137,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(
|
||||
|
||||
@@ -20,8 +20,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -58,31 +56,12 @@ def copy_class(
|
||||
geometry.change_object_data(obj, data, is_global=True)
|
||||
geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data)))
|
||||
# Only assign styles if element doesn't get them from material
|
||||
if not _has_material_styles(ifc, new):
|
||||
if not root.has_material_styles(new):
|
||||
root.assign_body_styles(new, obj)
|
||||
collector.assign(obj)
|
||||
return new
|
||||
|
||||
|
||||
def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool:
|
||||
"""Check if element has styles defined through its material.
|
||||
|
||||
Returns True if any constituent material has a style representation,
|
||||
which means styles should NOT be applied directly to the geometry.
|
||||
"""
|
||||
materials = ifcopenshell.util.element.get_materials(element)
|
||||
|
||||
if not materials:
|
||||
return False
|
||||
|
||||
# Check if any of the constituent materials have styles
|
||||
for material in materials:
|
||||
if hasattr(material, "HasRepresentation") and material.HasRepresentation:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def assign_class(
|
||||
ifc: type[tool.Ifc],
|
||||
collector: type[tool.Collector],
|
||||
|
||||
@@ -64,7 +64,7 @@ def assign_container(
|
||||
spatial.disable_editing(obj)
|
||||
all_elements.add(root_element)
|
||||
all_elements.update(spatial.get_decomposition(root_element))
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, e)]:
|
||||
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
||||
for element in all_elements:
|
||||
if obj := ifc.get_object(element):
|
||||
|
||||
@@ -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
|
||||
@@ -459,7 +467,6 @@ class Geometry:
|
||||
def has_axis_representation(cls, element): pass
|
||||
def has_data_users(cls, data): pass
|
||||
def has_material_style_override(cls, obj): pass
|
||||
def has_material_styles(cls, element): pass
|
||||
def import_representation_parameters(cls, data): pass
|
||||
def is_body_representation(cls, representation): pass
|
||||
def is_box_representation(cls, representation): pass
|
||||
@@ -695,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
|
||||
|
||||
|
||||
@@ -796,7 +805,7 @@ class Profile:
|
||||
@interface
|
||||
class Parametric:
|
||||
def get_geom_generation(cls) -> int: pass
|
||||
def refresh_post_commit(cls) -> None: pass
|
||||
def refresh_post_commit(cls, operator) -> None: pass
|
||||
|
||||
|
||||
@interface
|
||||
@@ -888,6 +897,7 @@ class Root:
|
||||
def get_object_name(cls, obj): pass
|
||||
def get_object_representation(cls, obj): pass
|
||||
def get_representation_context(cls, representation): pass
|
||||
def has_material_styles(cls, element): pass
|
||||
def is_containable(cls, element): pass
|
||||
def is_drawing_annotation(cls, element): pass
|
||||
def is_element_a(cls, element, ifc_class): 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
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
@@ -29,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
|
||||
@@ -46,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
|
||||
|
||||
@@ -229,15 +240,22 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
@classmethod
|
||||
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
|
||||
"""Gets the active object
|
||||
"""Return the active object, or ``None`` when the current context
|
||||
exposes neither ``active_object`` nor a ``view_layer`` (stripped
|
||||
operator contexts).
|
||||
|
||||
:param is_selected: If true, the active object also needs to be selected.
|
||||
"""
|
||||
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active):
|
||||
if not is_selected:
|
||||
return obj
|
||||
if obj.select_get():
|
||||
return obj
|
||||
obj = getattr(bpy.context, "active_object", None)
|
||||
if obj is None:
|
||||
view_layer = getattr(bpy.context, "view_layer", None)
|
||||
if view_layer is not None:
|
||||
obj = view_layer.objects.active
|
||||
if obj is None:
|
||||
return None
|
||||
if is_selected and not obj.select_get():
|
||||
return None
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
|
||||
@@ -567,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.
|
||||
@@ -880,19 +1012,57 @@ class Blender(bonsai.core.tool.Blender):
|
||||
# ( 1.0, 1.0, -1.0), # 7
|
||||
# ]
|
||||
bound_box = obj.bound_box
|
||||
min_pt = Vector(bound_box[0])
|
||||
max_pt = Vector(bound_box[6])
|
||||
bbox_dict = {
|
||||
"min_x": bound_box[0][0],
|
||||
"max_x": bound_box[6][0],
|
||||
"min_y": bound_box[0][1],
|
||||
"max_y": bound_box[6][1],
|
||||
"min_z": bound_box[0][2],
|
||||
"max_z": bound_box[6][2],
|
||||
"min_point": Vector(bound_box[0]),
|
||||
"max_point": Vector(bound_box[6]),
|
||||
"center": (Vector(bound_box[6]) + Vector(bound_box[0])) / 2,
|
||||
"min_x": min_pt.x,
|
||||
"max_x": max_pt.x,
|
||||
"min_y": min_pt.y,
|
||||
"max_y": max_pt.y,
|
||||
"min_z": min_pt.z,
|
||||
"max_z": max_pt.z,
|
||||
"min_point": min_pt,
|
||||
"max_point": max_pt,
|
||||
"center": (max_pt + min_pt) / 2,
|
||||
# Intrinsic per-axis size in object-local space. Distinct from
|
||||
# ``obj.dimensions``, which folds object-level scale into its
|
||||
# output; this is the raw mesh bbox extent.
|
||||
"dimensions": (max_pt.x - min_pt.x, max_pt.y - min_pt.y, max_pt.z - min_pt.z),
|
||||
}
|
||||
return bbox_dict
|
||||
|
||||
@classmethod
|
||||
def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
|
||||
"""Same shape as ``get_object_bounding_box`` but with ``matrix_world``
|
||||
applied — extents are computed across the 8 transformed corners, so
|
||||
a rotated or scaled object reports its actual world-axis AABB rather
|
||||
than the misleading transform of the local-space corners.
|
||||
|
||||
``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
|
||||
do NOT correspond to the world AABB extremes once the object is
|
||||
rotated, so min/max must be taken per-axis across all 8 corners."""
|
||||
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
xs = [c.x for c in corners]
|
||||
ys = [c.y for c in corners]
|
||||
zs = [c.z for c in corners]
|
||||
min_point = Vector((min(xs), min(ys), min(zs)))
|
||||
max_point = Vector((max(xs), max(ys), max(zs)))
|
||||
return {
|
||||
"min_x": min_point.x,
|
||||
"max_x": max_point.x,
|
||||
"min_y": min_point.y,
|
||||
"max_y": max_point.y,
|
||||
"min_z": min_point.z,
|
||||
"max_z": max_point.z,
|
||||
"min_point": min_point,
|
||||
"max_point": max_point,
|
||||
"center": (min_point + max_point) / 2,
|
||||
# World-axis-aligned per-axis size. For rotated objects this is
|
||||
# the AABB extent, not the intrinsic mesh size (use the local
|
||||
# variant for that).
|
||||
"dimensions": (max_point.x - min_point.x, max_point.y - min_point.y, max_point.z - min_point.z),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
|
||||
for obj in context.selected_objects:
|
||||
@@ -1228,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
|
||||
@@ -1332,74 +1505,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return True
|
||||
|
||||
class Modifier:
|
||||
# ----------------------------------------------------------------------
|
||||
# FIXME(PR5): backward-compat shims for callers still using the
|
||||
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
|
||||
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
|
||||
# this whole shim block is removed in PR5's cleanup.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def is_stair(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
@classmethod
|
||||
def is_wall(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_wall(element)
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
class Array:
|
||||
@classmethod
|
||||
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
|
||||
tool.Array.bake_children_transform(parent_element, item)
|
||||
|
||||
@classmethod
|
||||
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.constrain_children_to_parent(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_children_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_children_objects(cls, modifier_data: dict) -> list:
|
||||
return tool.Array.get_children_objects(modifier_data)
|
||||
|
||||
@classmethod
|
||||
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
|
||||
return tool.Array.get_modifiers_data(parent_element)
|
||||
|
||||
@classmethod
|
||||
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.remove_constraints(parent_element)
|
||||
|
||||
@classmethod
|
||||
def set_children_lock_state(
|
||||
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
|
||||
) -> None:
|
||||
tool.Array.set_children_lock_state(parent_element, item, lock)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
|
||||
"""Tries to validate the current BIM modifier parameters for the active object
|
||||
@@ -1481,6 +1586,38 @@ class Blender(bonsai.core.tool.Blender):
|
||||
parent_guid = pset.get("Parent")
|
||||
return parent_guid is not None and parent_guid != element.GlobalId
|
||||
|
||||
@classmethod
|
||||
def any_selected_is_array_child(cls) -> bool:
|
||||
"""True if any selected IFC-linked object is a Bonsai array child.
|
||||
|
||||
Multi-object wall topology gizmos (merge / join / extend / unjoin
|
||||
/ fillet) and their bound operators gate on this: any mutation
|
||||
applied to a child is overwritten on the next
|
||||
``regenerate_array``, and merge specifically would leave the
|
||||
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID.
|
||||
|
||||
Memoised against (selection signature, IFC geometry generation)
|
||||
so gizmo polls that fire per input event don't re-walk the pset
|
||||
for every selected object every frame. Identity-keyed so plain
|
||||
Python objects (used by tests) work alongside real Blender
|
||||
``bpy_struct`` wrappers."""
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
selection_sig = frozenset(id(obj) for obj in selected)
|
||||
current_gen = tool.Parametric.get_geom_generation()
|
||||
cached = cls._any_selected_array_child_memo
|
||||
if cached is not None and cached[0] == selection_sig and cached[1] == current_gen:
|
||||
return cached[2]
|
||||
result = False
|
||||
for obj in selected:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is not None and cls.is_array_child(element):
|
||||
result = True
|
||||
break
|
||||
cls._any_selected_array_child_memo = (selection_sig, current_gen, result)
|
||||
return result
|
||||
|
||||
_any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None
|
||||
|
||||
@classmethod
|
||||
def is_slab(cls, element: entity_instance) -> bool:
|
||||
"""A slab is host-eligible for the parametric add-opening gizmo if
|
||||
@@ -2018,6 +2155,18 @@ class Blender(bonsai.core.tool.Blender):
|
||||
dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())}
|
||||
return types.MappingProxyType(dct)
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
def get_property_header_tools(cls) -> frozenset[str]:
|
||||
"""``BimTool`` plus its parametric subclasses — the workspace
|
||||
tools whose 3D-view / N-panel header surfaces BIM Tool property
|
||||
floats (extrusion_depth, length, x_angle). ``AnnotationTool``
|
||||
and the non-``BimTool`` workspace tools (spatial / structural /
|
||||
cad / covering) are excluded by construction."""
|
||||
from bonsai.bim.module.model.workspace import BimTool
|
||||
|
||||
return frozenset(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool]))
|
||||
|
||||
@classmethod
|
||||
def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties:
|
||||
return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -2238,6 +2387,142 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def draw_bmesh_face_tris(
|
||||
cls,
|
||||
bm: bmesh.types.BMesh,
|
||||
world_vert_coords: list,
|
||||
color: Any,
|
||||
draw_batch: Callable[[str, list, Any, list], None],
|
||||
) -> None:
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces.
|
||||
|
||||
``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call
|
||||
``bmesh.ops.triangulate`` on a live bmesh to compute draw indices — it
|
||||
mutates the input and produces ear-clip fans that render as visible
|
||||
streaks at low alpha.
|
||||
"""
|
||||
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,
|
||||
world_verts: Sequence[Sequence[float]],
|
||||
edges_indices: Sequence[Sequence[int]],
|
||||
dash_period: float,
|
||||
dash_width: float,
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]:
|
||||
"""Pre-segment edges into world-space dash chunks for a vanilla LINES batch.
|
||||
|
||||
Each input edge is sliced into segments of length ``dash_width`` spaced
|
||||
``dash_period`` apart (dash phase resets per-edge). The result is a fresh
|
||||
``(verts, edges)`` pair that draws as dashes through any standard line
|
||||
shader — letting both passes of a visible/occluded outline reuse the
|
||||
same shader so depth values match exactly across passes.
|
||||
"""
|
||||
new_verts: list[tuple[float, float, float]] = []
|
||||
new_edges: list[tuple[int, int]] = []
|
||||
if dash_period <= 0 or dash_width <= 0:
|
||||
return new_verts, new_edges
|
||||
n = len(world_verts)
|
||||
for i, j in edges_indices:
|
||||
if not (0 <= i < n and 0 <= j < n) or i == j:
|
||||
continue
|
||||
v0 = world_verts[i]
|
||||
v1 = world_verts[j]
|
||||
dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
|
||||
edge_length = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if edge_length == 0.0:
|
||||
continue
|
||||
ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length
|
||||
t = 0.0
|
||||
while t < edge_length:
|
||||
t_end = min(t + dash_width, edge_length)
|
||||
idx = len(new_verts)
|
||||
new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t))
|
||||
new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end))
|
||||
new_edges.append((idx, idx + 1))
|
||||
t += dash_period
|
||||
return new_verts, new_edges
|
||||
|
||||
@classmethod
|
||||
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
|
||||
"""Extracts error report lines from a runtime exception during operator execution.
|
||||
@@ -2388,7 +2673,7 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
See https://projects.blender.org/blender/blender/issues/149283
|
||||
"""
|
||||
if len(bytedata) == (n * 2):
|
||||
if len(bytedata) == (n * 8): # float64 has 8 bytes per element
|
||||
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
|
||||
return np.frombuffer(bytedata, dtype=np.float32)
|
||||
|
||||
|
||||
@@ -177,6 +177,14 @@ class Cad:
|
||||
return False
|
||||
return (x + tolerance) > value > (x - tolerance)
|
||||
|
||||
@classmethod
|
||||
def is_multiple_of_pi(cls, value: float) -> bool:
|
||||
"""True when ``value`` is an integer multiple of π within tolerance —
|
||||
the parallelism / anti-parallelism check rotation-difference logic
|
||||
reaches for (segments aligned modulo a 180° flip)."""
|
||||
n = round(value / math.pi)
|
||||
return cls.is_x(abs(value - n * math.pi), 0)
|
||||
|
||||
@classmethod
|
||||
def normalise_angle(cls, angle: float) -> float:
|
||||
"""Normalise an angle between -179 and 180"""
|
||||
@@ -198,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:
|
||||
@@ -849,15 +988,6 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when any of ``element``'s materials exposes an
|
||||
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
|
||||
return any(
|
||||
tool.Material.get_style(material) is not None
|
||||
for material in ifcopenshell.util.element.get_materials(element)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reimport_element_representations(
|
||||
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
|
||||
@@ -2357,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():
|
||||
@@ -2439,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)
|
||||
|
||||
@@ -1216,7 +1216,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
) -> bool:
|
||||
items = [i["item"] for i in ifcopenshell.util.representation.resolve_items(representation)]
|
||||
if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"):
|
||||
if tool.Blender.Modifier.is_railing(element):
|
||||
if tool.Parametric.is_railing(element):
|
||||
return False
|
||||
return True
|
||||
elif len(items) and ( # See #2508 why we accommodate for invalid IFCs here
|
||||
@@ -1224,7 +1224,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
and len({i.is_a() for i in items}) == 1
|
||||
and len({i.Radius for i in items}) == 1
|
||||
):
|
||||
if tool.Blender.Modifier.is_railing(element):
|
||||
if tool.Parametric.is_railing(element):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -227,10 +227,8 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None:
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
new_origin = obj.matrix_world.translation.copy()
|
||||
new_origin[2] = min_z
|
||||
new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
|
||||
assert isinstance(obj.data, bpy.types.Mesh)
|
||||
obj.data.transform(
|
||||
Matrix.Translation(
|
||||
@@ -249,11 +247,8 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
max_z = max([c[2] for c in absolute_bound_box])
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
current_absolute_height = max_z - min_z
|
||||
scale_factor = height / current_absolute_height
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
scale_factor = height / (bbox["max_z"] - bbox["min_z"])
|
||||
obj.matrix_world @= Matrix.Scale(
|
||||
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
|
||||
)
|
||||
|
||||
+106
-11
@@ -75,11 +75,14 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import (
|
||||
BIMArrayProperties,
|
||||
BIMDoorProperties,
|
||||
BIMDuctSegmentProperties,
|
||||
BIMExternalParametricGeometryProperties,
|
||||
BIMModelProperties,
|
||||
BIMPipeSegmentProperties,
|
||||
BIMPolylineProperties,
|
||||
BIMRailingProperties,
|
||||
BIMRoofProperties,
|
||||
BIMSlabProperties,
|
||||
BIMStairProperties,
|
||||
BIMSverchokProperties,
|
||||
BIMWallProperties,
|
||||
@@ -116,6 +119,18 @@ 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]
|
||||
|
||||
@classmethod
|
||||
def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties:
|
||||
return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
|
||||
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -362,6 +377,28 @@ class Model(bonsai.core.tool.Model):
|
||||
else:
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def get_sibling_occurrence_count(cls, element: ifcopenshell.entity_instance) -> int:
|
||||
"""Number of *other* products sharing this element's body representation.
|
||||
|
||||
Returns the count of products bound to the same resolved body rep, minus
|
||||
``element`` itself and minus its type (if any). Zero when the element has
|
||||
no body rep, no resolved rep, or no siblings. A non-zero result means a
|
||||
parametric edit on ``element`` will silently mutate other instances'
|
||||
geometry."""
|
||||
body_rep = tool.Geometry.get_body_representation(element)
|
||||
if not body_rep:
|
||||
return 0
|
||||
resolved = ifcopenshell.util.representation.resolve_representation(body_rep)
|
||||
if not resolved:
|
||||
return 0
|
||||
elements = tool.Geometry.get_elements_by_representation(resolved)
|
||||
elements.discard(element)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is not None:
|
||||
elements.discard(element_type)
|
||||
return len(elements)
|
||||
|
||||
unit_scale: float
|
||||
vertices: list[Vector]
|
||||
edges: list[Sequence[int]]
|
||||
@@ -777,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
|
||||
@@ -785,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":
|
||||
@@ -798,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":
|
||||
@@ -809,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
|
||||
|
||||
@@ -872,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."""
|
||||
@@ -1243,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):
|
||||
@@ -1556,8 +1636,7 @@ class Model(bonsai.core.tool.Model):
|
||||
element = tool.Ifc.get_entity(object)
|
||||
if not element:
|
||||
return
|
||||
psets = ifcopenshell.util.element.get_psets(element)
|
||||
pset_data = psets.get(pset_name, None)
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, pset_name)
|
||||
if not pset_data:
|
||||
return
|
||||
pset_data["data_dict"] = json.loads(pset_data.get("Data", "[]") or "[]")
|
||||
@@ -2943,7 +3022,7 @@ class Model(bonsai.core.tool.Model):
|
||||
def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTER"]) -> None:
|
||||
element = tool.Ifc.get_entity(wall)
|
||||
usage = ifcopenshell.util.element.get_material(element)
|
||||
if not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
if usage is None or not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
return
|
||||
layer_set = usage.ForLayerSet
|
||||
if baseline == "CENTER":
|
||||
@@ -2979,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,
|
||||
@@ -2993,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()
|
||||
|
||||
@@ -147,18 +147,17 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
self._data.clear()
|
||||
self._gen = None
|
||||
|
||||
# FIXME(PR4): array / pipe_segment / duct_segment land with their
|
||||
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
|
||||
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
|
||||
# for objects flagged as in-edit, which then raises because the operator
|
||||
# doesn't exist. PR4 re-adds the three entries together with the operators.
|
||||
EDIT_TYPES: list[ParametricObject] = [
|
||||
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("railing", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("roof", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("array", supports_build_edit_lifecycle=True),
|
||||
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
|
||||
@@ -169,7 +168,11 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
STAIR: ClassVar[ParametricObject]
|
||||
RAILING: ClassVar[ParametricObject]
|
||||
ROOF: ClassVar[ParametricObject]
|
||||
ARRAY: ClassVar[ParametricObject]
|
||||
PIPE_SEGMENT: ClassVar[ParametricObject]
|
||||
DUCT_SEGMENT: ClassVar[ParametricObject]
|
||||
WALL: ClassVar[ParametricObject]
|
||||
SLAB: ClassVar[ParametricObject]
|
||||
|
||||
_geom_generation: int = 0
|
||||
|
||||
@@ -178,16 +181,24 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
return cls._geom_generation
|
||||
|
||||
@classmethod
|
||||
def refresh_post_commit(cls) -> None:
|
||||
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
|
||||
workspace-tool header fields from current IFC state and bumps the
|
||||
geometry generation counter so caches keyed off it drop stale
|
||||
entries on the next draw."""
|
||||
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
||||
def refresh_post_commit(cls, operator: bpy.types.Operator) -> None:
|
||||
"""Post-commit hook for ``tool.Ifc.Operator``: bumps the geometry
|
||||
generation counter so caches keyed off it drop stale entries on
|
||||
the next draw, and tags viewports for redraw.
|
||||
|
||||
Additionally refreshes the BIM Tool header floats for the
|
||||
validate-gizmo path — operators whose ``bl_idname`` is the
|
||||
``finish_op`` of an entry in ``EDIT_TYPES``. That is the only
|
||||
commit class where selection didn't change but the header
|
||||
values displayed did. Other operators skip the refresh: they
|
||||
don't target an active-object header edit, and their commit
|
||||
context may lack the view-layer attributes the refresh reads."""
|
||||
cls._geom_generation += 1
|
||||
bonsai.bim.handler.update_bim_tool_props()
|
||||
tool.Blender.update_all_viewports()
|
||||
if operator.bl_idname in {feature.finish_op for feature in cls.EDIT_TYPES}:
|
||||
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
||||
|
||||
bonsai.bim.handler.refresh_bim_tool_headers()
|
||||
|
||||
@classmethod
|
||||
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
|
||||
@@ -262,6 +273,18 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
for obj in bpy.data.objects:
|
||||
cls._validated_editing_feature(obj)
|
||||
|
||||
@classmethod
|
||||
def on_load_post(cls, scene: bpy.types.Scene) -> None:
|
||||
"""Drain load-transient parametric state on a freshly opened scene
|
||||
so no draft edit flag, preview flag, or cache entry persists from
|
||||
the saved file."""
|
||||
from bonsai.bim.module.model import wall_offset_gizmos
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
|
||||
cls.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
wall_offset_gizmos.clear_caches()
|
||||
|
||||
@classmethod
|
||||
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
|
||||
"""``(object, finish_operator_bl_idname)`` pairs for every object
|
||||
@@ -382,29 +405,6 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
if hasattr(bpy.types.Object, feature.props_attr):
|
||||
delattr(bpy.types.Object, feature.props_attr)
|
||||
|
||||
@classmethod
|
||||
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
|
||||
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
|
||||
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
|
||||
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
|
||||
inject the per-type ``GizmoPreferences<X>`` classes at the correct
|
||||
point — before ``ui.GizmoPreferences``, which references them via
|
||||
``PointerProperty``."""
|
||||
# FIXME(PR5): drop the per-feature loop once PR4 consolidates
|
||||
# bim/ui.py to use a single shared GizmoPreferencesFeature class
|
||||
# and rewrites GizmoPreferences accordingly. The shared-class
|
||||
# branch is the forward-compat path; the per-feature loop keeps
|
||||
# v0.8.0's bim/ui.py working until then.
|
||||
out: list[type] = []
|
||||
for feature in cls.EDIT_TYPES:
|
||||
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
|
||||
if gpref is not None:
|
||||
out.append(gpref)
|
||||
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
|
||||
if shared is not None:
|
||||
out.append(shared)
|
||||
return out
|
||||
|
||||
# --- Feature-kind predicates ------------------------------------------------
|
||||
# One predicate per registered parametric type. Each is total: accepts any
|
||||
# IFC entity (or None), returns a bool, never raises. Predicates live with
|
||||
@@ -461,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
|
||||
|
||||
|
||||
@@ -373,7 +373,6 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
|
||||
snap_obj._ensure_bvh()
|
||||
intersected = snap_obj.raycast_boxes(
|
||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||
@@ -395,13 +394,10 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
# Lazily project only the needed vertices to 2D screen space
|
||||
verts_2d: dict[int, Vector] = {}
|
||||
for idx in verts_idx:
|
||||
v2d = view3d_utils.location_3d_to_region_2d(
|
||||
region, rv3d, snap_obj.verts_3d[idx]
|
||||
)
|
||||
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_obj.verts_3d[idx])
|
||||
if v2d is not None:
|
||||
verts_2d[idx] = v2d
|
||||
|
||||
|
||||
edge_verts = {}
|
||||
for e in edges:
|
||||
verts_idx = snap_obj.obj.data.edges[e].vertices
|
||||
@@ -885,9 +881,7 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
# Process wireframe objects first (all of them, always collected)
|
||||
for snap_obj in wireframe_objs:
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(
|
||||
context, event, snap_obj, ray_origin, closest_snaps
|
||||
)
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
@@ -926,9 +920,7 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(
|
||||
context, event, snap_obj, ray_origin, closest_snaps
|
||||
)
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
|
||||
face_index = None
|
||||
else:
|
||||
# Solid objects
|
||||
@@ -977,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.
|
||||
|
||||
@@ -71,6 +71,18 @@ class Root(bonsai.core.tool.Root):
|
||||
should_use_presentation_style_assignment=props.should_use_presentation_style_assignment,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""``True`` if any constituent material on ``element`` carries a style
|
||||
representation. Body styles should NOT be applied directly when this
|
||||
is True — the material-inherited style is the authoritative source.
|
||||
Paired with ``assign_body_styles``: callers check this first and only
|
||||
call ``assign_body_styles`` when it returns False."""
|
||||
materials = ifcopenshell.util.element.get_materials(element)
|
||||
if not materials:
|
||||
return False
|
||||
return any(getattr(m, "HasRepresentation", None) for m in materials)
|
||||
|
||||
@classmethod
|
||||
def copy_representation(
|
||||
cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance
|
||||
@@ -381,7 +393,7 @@ class Root(bonsai.core.tool.Root):
|
||||
# Make sure that the array children also get reassigned to the correct aggregate
|
||||
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
|
||||
if pset:
|
||||
array_children = tool.Blender.Modifier.Array.get_all_children_objects(new[0])
|
||||
array_children = tool.Array.get_all_children_objects(new[0])
|
||||
for obj in array_children:
|
||||
bonsai.core.aggregate.assign_object(
|
||||
tool.Ifc,
|
||||
|
||||
@@ -80,10 +80,7 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
||||
while True:
|
||||
if parent := (
|
||||
ifcopenshell.util.element.get_aggregate(element)
|
||||
or ifcopenshell.util.element.get_nest(element)
|
||||
or ifcopenshell.util.element.get_filled_void(element)
|
||||
or ifcopenshell.util.element.get_voided_element(element)
|
||||
ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
|
||||
):
|
||||
element = parent
|
||||
else:
|
||||
|
||||
@@ -488,6 +488,87 @@ 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
|
||||
(``IfcExtrudedAreaSolid`` for segments, ``IfcSweptDiskSolid`` for
|
||||
fittings) — the shape the parametric edit + MEP action gizmos can
|
||||
actually mutate. Tessellation- or brep-imported MEP elements return
|
||||
False so their gizmos hide rather than offer edits the geometry
|
||||
kernel can't honour."""
|
||||
import bonsai.tool as tool
|
||||
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
if body is None:
|
||||
return False
|
||||
for item in tool.Ifc.get().traverse(body):
|
||||
if item.is_a("IfcExtrudedAreaSolid") or item.is_a("IfcSweptDiskSolid"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def walk_connected_mep_elements(
|
||||
cls, start_element: ifcopenshell.entity_instance
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -687,8 +687,10 @@ Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC ps
|
||||
Then "active_object.BIMDoorProperties.is_editing" is "True"
|
||||
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
|
||||
Then "active_object.BIMDoorProperties.is_editing" is "False"
|
||||
# BBIM_<Type> psets store project units, not raw Blender SI. The empty project
|
||||
# used in an_empty_blender_session is METRIC_MM, so 2.5 m → 2500 mm in the pset.
|
||||
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
|
||||
And the variable "saved_height" equals "2.5"
|
||||
And the variable "saved_height" equals "2500.0"
|
||||
|
||||
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
|
||||
Given an empty IFC project
|
||||
@@ -705,14 +707,10 @@ Scenario: Saving with no parametric edits in progress leaves the door pset uncha
|
||||
|
||||
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.enable_editing_wall()"
|
||||
@@ -722,21 +720,18 @@ Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
|
||||
|
||||
Scenario: Enabling and finishing a wall edit with no drag is a no-op
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And the variable "entity_count_before" is "len(list({ifc}))"
|
||||
When I press "bim.enable_editing_wall()"
|
||||
And I press "bim.finish_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "False"
|
||||
And "len(list({ifc}))" is "{entity_count_before}"
|
||||
And the variable "entity_count_after" is "len(list({ifc}))"
|
||||
And the variable "entity_count_after" equals "{entity_count_before}"
|
||||
|
||||
Scenario: Cancelling a wall edit clears is_editing
|
||||
Given an empty IFC project
|
||||
@@ -756,14 +751,10 @@ Scenario: Cancelling a wall edit clears is_editing
|
||||
|
||||
Scenario: Wall parametric edit works on IFC2X3 projects
|
||||
Given an empty IFC2X3 project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
When I press "bim.enable_editing_wall()"
|
||||
|
||||
@@ -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,17 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2021 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/>.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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: ``HasShapeAspects`` is an IFC4+ inverse;
|
||||
direct attribute access raises ``AttributeError`` on pre-IFC4 entity
|
||||
instances. Production code must read it through ``getattr`` so the
|
||||
absence in earlier schemas degrades to an empty iterable."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.geometry
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai"
|
||||
PRODUCTION_DIRS = (BONSAI_ROOT / "bim", BONSAI_ROOT / "tool", BONSAI_ROOT / "core")
|
||||
|
||||
ATTR_NAME = "HasShapeAspects"
|
||||
|
||||
|
||||
def _iter_production_sources():
|
||||
for root in PRODUCTION_DIRS:
|
||||
yield from root.rglob("*.py")
|
||||
|
||||
|
||||
def test_has_shape_aspects_access_uses_getattr_guard():
|
||||
"""Every read of ``HasShapeAspects`` in production code must go through
|
||||
``getattr(<expr>, "HasShapeAspects", <default>)`` so files using
|
||||
schemas that omit the inverse return the default instead of raising."""
|
||||
offenders = []
|
||||
for source in _iter_production_sources():
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and node.attr == ATTR_NAME:
|
||||
offenders.append(f"{source.relative_to(BONSAI_ROOT.parent)}:{node.lineno}")
|
||||
if offenders:
|
||||
joined = "\n ".join(sorted(offenders))
|
||||
pytest.fail(
|
||||
f"Direct .{ATTR_NAME} attribute access in production code:\n {joined}\n"
|
||||
f"Wrap with getattr(<expr>, '{ATTR_NAME}', ()) so pre-IFC4 schemas "
|
||||
f"do not raise AttributeError."
|
||||
)
|
||||
@@ -0,0 +1,228 @@
|
||||
# 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 fixtures and factories for ``test/bim/module/model/`` gizmo and
|
||||
decorator tests.
|
||||
|
||||
The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched
|
||||
identically across many model-test files (viewport-state, selection, IFC
|
||||
entity lookup, modifier predicates, view-camera state). The ``patched_tool``
|
||||
fixture below centralises that patch stack so each test names only the
|
||||
boundary methods it cares about; everything else is left to production.
|
||||
|
||||
Factory helpers (``make_obj``, ``make_element``, ``make_context``,
|
||||
``make_ifc_file``) replace near-identical local helpers that previously
|
||||
lived in each file.
|
||||
|
||||
When to use these fixtures in a new test file:
|
||||
|
||||
- Adding a gizmo / decorator test that patches ``tool.Blender`` or
|
||||
``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture
|
||||
as a test parameter and call it as a context-manager factory.
|
||||
- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` /
|
||||
``poll()`` context / ``ifcopenshell.file``? Import the matching factory
|
||||
from this module rather than re-rolling locally.
|
||||
- Need to reset module-level state (e.g. a decorator cache token) between
|
||||
tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test
|
||||
file itself — these stay file-local because they target state specific
|
||||
to one decorator/module and globalising the reset would surprise
|
||||
unrelated tests.
|
||||
|
||||
Layout note: pure helpers (``make_*``) live alongside the fixture in this
|
||||
file rather than a sibling ``test_utils.py``. pytest's documented role for
|
||||
``conftest.py`` is fixtures, so this is a mild convention bend — kept here
|
||||
because the helper count is small and the dependencies (``tool``, ``Mock``)
|
||||
already need to be imported for the fixture itself. Split into a separate
|
||||
module if the helper count grows past ~6 or any helper picks up its own
|
||||
non-trivial dependencies."""
|
||||
|
||||
import contextlib
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
"""Skip every test in this directory when ``bpy`` is mocked or absent.
|
||||
|
||||
The model gizmo / decorator suite reaches into Blender's RNA layer
|
||||
(``bpy.types.Operator``, registered ``bl_idname`` lookups, ``Modifier``
|
||||
predicates) that ``Mock`` cannot impersonate, so a tool-lane run with a
|
||||
stubbed ``bpy`` would error rather than meaningfully exercise the
|
||||
contract. The autouse scope means new test files added under this
|
||||
directory inherit the gate without re-declaring it."""
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def make_obj(*, session_uid=None, selected=True, **attrs):
|
||||
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
|
||||
|
||||
``session_uid`` is set only when provided so tests that don't care about
|
||||
object identity (most poll() tests use ``object()`` sentinels) can use
|
||||
``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()``
|
||||
to return the given boolean. Extra attrs are set as plain attributes.
|
||||
|
||||
A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)``
|
||||
rejects ``select_get`` — Blender's C-registered methods aren't exposed
|
||||
to Python introspection."""
|
||||
obj = Mock()
|
||||
if session_uid is not None:
|
||||
obj.session_uid = session_uid
|
||||
obj.select_get.return_value = selected
|
||||
for name, value in attrs.items():
|
||||
setattr(obj, name, value)
|
||||
return obj
|
||||
|
||||
|
||||
def make_element(step_id=None, *, ifc_class=None, **attrs):
|
||||
"""Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read.
|
||||
|
||||
``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)``
|
||||
to return True only when ``name == ifc_class``. Extra kwargs become plain
|
||||
attributes (e.g. ``HasOpenings=()``)."""
|
||||
element = Mock()
|
||||
if step_id is not None:
|
||||
element.id.return_value = step_id
|
||||
if ifc_class is not None:
|
||||
element.is_a.side_effect = lambda type_name: type_name == ifc_class
|
||||
for name, value in attrs.items():
|
||||
setattr(element, name, value)
|
||||
return element
|
||||
|
||||
|
||||
def make_context(*, active=None, selected=(), scene=None):
|
||||
"""``SimpleNamespace`` stub with the ``poll()`` reads tests exercise:
|
||||
``active_object``, ``selected_objects``, and ``scene``. ``selected`` is
|
||||
materialised to a list so tests can iterate without re-walking a generator.
|
||||
``scene`` defaults to an empty namespace so guards that walk
|
||||
``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``)
|
||||
treat the preview as inactive — pass a custom namespace to activate."""
|
||||
return SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=list(selected),
|
||||
scene=scene if scene is not None else SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock:
|
||||
"""Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as
|
||||
``AttributeError`` instead of silently auto-creating a child mock.
|
||||
|
||||
When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the
|
||||
mapping and raise ``RuntimeError`` on a missing guid — same shape as the
|
||||
real ifcopenshell.file behaviour, so a test that depends on orphan handling
|
||||
sees an exception rather than a silent ``None``."""
|
||||
f = MagicMock(spec=ifcopenshell.file, name="ifc_file")
|
||||
if elements_by_guid is not None:
|
||||
|
||||
def _by_guid(guid):
|
||||
try:
|
||||
return elements_by_guid[guid]
|
||||
except KeyError:
|
||||
raise RuntimeError(f"no entity with guid {guid}")
|
||||
|
||||
f.by_guid.side_effect = _by_guid
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_tool():
|
||||
"""Context-manager factory for the ``tool.*`` boundary patches that nearly
|
||||
every gizmo / decorator test repeats. Use as::
|
||||
|
||||
with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b],
|
||||
modifier_predicates={"is_wall": True}):
|
||||
GizmoFoo.poll(context)
|
||||
|
||||
Only the kwargs you pass are patched — anything left as ``None`` (or
|
||||
omitted) keeps production behaviour. Values can be:
|
||||
|
||||
- ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to
|
||||
``return_value=`` of the corresponding patch.
|
||||
- ``selected``: wrapped in ``set(...)`` for ``get_selected_objects``
|
||||
(matches the production return type for ``poll()``-side reads).
|
||||
- ``selected_list``: as-is for ``get_selected_objects`` when order
|
||||
matters (some operators iterate it). Mutually exclusive with
|
||||
``selected`` — if both are passed, ``selected`` wins and
|
||||
``selected_list`` is ignored. Pass only one.
|
||||
- ``entity``: either a callable (used as ``side_effect``) or a single
|
||||
value (used as ``return_value``).
|
||||
- ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``.
|
||||
Callables are wired as ``side_effect``, bools as ``return_value``.
|
||||
- ``screen_up``: ``return_value`` for ``get_screen_up_world``.
|
||||
|
||||
Patches close on context-manager exit via an ``ExitStack`` — no
|
||||
``try/finally`` bookkeeping in the test body."""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _factory(
|
||||
*,
|
||||
viewport_gizmos=None,
|
||||
addon_prefs=None,
|
||||
selected=None,
|
||||
selected_list=None,
|
||||
entity=None,
|
||||
modifier_predicates=None,
|
||||
view_top_down=None,
|
||||
screen_up=None,
|
||||
):
|
||||
with contextlib.ExitStack() as stack:
|
||||
if viewport_gizmos is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos)
|
||||
)
|
||||
if addon_prefs is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs))
|
||||
if selected is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)))
|
||||
elif selected_list is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list))
|
||||
)
|
||||
if entity is not None:
|
||||
if callable(entity):
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity))
|
||||
else:
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity))
|
||||
if modifier_predicates:
|
||||
for name, value in modifier_predicates.items():
|
||||
# Parametric feature-kind predicates live on tool.Parametric; the
|
||||
# remaining cardinality / non-parametric predicates (is_array_child,
|
||||
# is_slab, is_eligible_for_*) stay on tool.Blender.Modifier.
|
||||
target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier
|
||||
if callable(value):
|
||||
stack.enter_context(patch.object(target, name, side_effect=value))
|
||||
else:
|
||||
stack.enter_context(patch.object(target, name, return_value=value))
|
||||
if view_top_down is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down))
|
||||
if screen_up is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up))
|
||||
yield
|
||||
|
||||
return _factory
|
||||
@@ -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,85 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract: decorators do not triangulate in-place.
|
||||
|
||||
``bmesh.ops.triangulate(bm, faces=bm.faces)`` mutates its input — adding tri
|
||||
edges and faces — and uses ear-clip fan triangulation that renders as visible
|
||||
streaks across n-gon faces at the low alphas decorators favour. The canonical
|
||||
draw path is ``tool.Blender.draw_bmesh_face_tris`` (wraps ``bm.calc_loop_triangles``,
|
||||
non-mutating, beauty triangulator)."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
BIM_MODULE_DIR = BONSAI_ROOT / "bim" / "module"
|
||||
|
||||
|
||||
def _iter_guarded_files():
|
||||
yield from sorted(BIM_MODULE_DIR.glob("*/decorator.py"))
|
||||
yield BIM_MODULE_DIR / "model" / "opening.py"
|
||||
|
||||
|
||||
def _is_guarded_class(node: ast.ClassDef) -> bool:
|
||||
return node.name.endswith("Decorator") or node.name == "DecorationsHandler"
|
||||
|
||||
|
||||
def _is_mutating_triangulate_call(node: ast.AST) -> bool:
|
||||
if not isinstance(node, ast.Call):
|
||||
return False
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "triangulate":
|
||||
return False
|
||||
receiver = func.value
|
||||
if not isinstance(receiver, ast.Attribute) or receiver.attr != "ops":
|
||||
return False
|
||||
inner = receiver.value
|
||||
return isinstance(inner, ast.Name) and inner.id == "bmesh"
|
||||
|
||||
|
||||
def test_no_decorator_calls_bmesh_ops_triangulate() -> None:
|
||||
violations: list[str] = []
|
||||
guarded_files = list(_iter_guarded_files())
|
||||
assert guarded_files, "Search root contains no decorator modules — test needs updating."
|
||||
|
||||
for path in guarded_files:
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, FileNotFoundError):
|
||||
continue
|
||||
for class_node in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
|
||||
if not _is_guarded_class(class_node):
|
||||
continue
|
||||
for sub in ast.walk(class_node):
|
||||
if _is_mutating_triangulate_call(sub):
|
||||
violations.append(f"{path}:{sub.lineno} {class_node.name} calls bmesh.ops.triangulate")
|
||||
|
||||
assert not violations, (
|
||||
"Decorator classes must not call bmesh.ops.triangulate — it mutates "
|
||||
"the input bmesh and produces fan-clip artefacts at low alpha. "
|
||||
"Use tool.Blender.draw_bmesh_face_tris (wraps bm.calc_loop_triangles). "
|
||||
"Violations:\n " + "\n ".join(violations)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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.
|
||||
|
||||
"""Contract tests for the door swing-arc readonly decorator.
|
||||
|
||||
Two layers:
|
||||
|
||||
- Pure tests on ``_visible_arcs`` pin the readonly decorator's arc selection
|
||||
per ``door_type`` enum value.
|
||||
- A forward-compat guard walks ``GizmoDoorEdition.swing_arc_props`` and
|
||||
asserts the readonly decorator picks the same arcs (hinge / width / mirror)
|
||||
the edit-mode gizmo would, so the two surfaces stay visually identical
|
||||
even when a new ``door_type`` is added."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import get_args
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _visible_arcs — per-door-type arc selection
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _arcs(door_type, overall_width=0.9, lining_offset=0.05):
|
||||
from bonsai.bim.module.model.decorator import _visible_arcs
|
||||
|
||||
return _visible_arcs(door_type, overall_width, lining_offset)
|
||||
|
||||
|
||||
def test_single_swing_left_one_arc_hinged_at_origin():
|
||||
arcs = _arcs("SINGLE_SWING_LEFT")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.0)
|
||||
assert arc.hinge_y == pytest.approx(0.05)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is False
|
||||
|
||||
|
||||
def test_single_swing_right_one_arc_hinged_at_right_edge_x_mirrored():
|
||||
arcs = _arcs("SINGLE_SWING_RIGHT")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.9)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["DOUBLE_SWING_LEFT", "DOUBLE_SWING_RIGHT"])
|
||||
def test_double_swing_shares_recipe_with_single_swing(door_type):
|
||||
# DOUBLE_SWING_* is still a single panel (the hinge is on one side,
|
||||
# the panel swings both ways) — visually identical to SINGLE_SWING_*.
|
||||
single_type = door_type.replace("DOUBLE_SWING", "SINGLE_SWING")
|
||||
assert _arcs(door_type) == _arcs(single_type)
|
||||
|
||||
|
||||
def test_double_door_single_swing_emits_two_half_width_arcs():
|
||||
arcs = _arcs("DOUBLE_DOOR_SINGLE_SWING")
|
||||
assert len(arcs) == 2
|
||||
left, right = arcs
|
||||
assert left.hinge_x == pytest.approx(0.0)
|
||||
assert left.panel_width == pytest.approx(0.45)
|
||||
assert left.x_mirror is False
|
||||
assert right.hinge_x == pytest.approx(0.9)
|
||||
assert right.panel_width == pytest.approx(0.45)
|
||||
assert right.x_mirror is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
|
||||
def test_sliding_doors_emit_no_arcs(door_type):
|
||||
assert _arcs(door_type) == []
|
||||
|
||||
|
||||
def test_unknown_door_type_falls_back_to_single_left_swing_arc():
|
||||
# Only ``"SLIDING"`` substrings short-circuit the swing predicate; any
|
||||
# other novel ``door_type`` falls through to the default left-hinged arc.
|
||||
arcs = _arcs("FUTURE_OPERATION_TYPE_42")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.0)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is False
|
||||
|
||||
|
||||
def test_lining_offset_drives_hinge_y_for_every_visible_arc():
|
||||
for door_type in ("SINGLE_SWING_LEFT", "SINGLE_SWING_RIGHT", "DOUBLE_DOOR_SINGLE_SWING"):
|
||||
for arc in _arcs(door_type, overall_width=0.9, lining_offset=0.12):
|
||||
assert arc.hinge_y == pytest.approx(0.12)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Forward-compat: readonly decorator and edit-mode gizmo agree per door_type
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gizmo_expected(door_type, overall_width, lining_offset):
|
||||
"""What ``GizmoDoorEdition.swing_arc_props`` would render for the props
|
||||
snapshot, with ``is_editing=True`` so its visibility predicates pass."""
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
props = SimpleNamespace(
|
||||
door_type=door_type,
|
||||
overall_width=overall_width,
|
||||
lining_offset=lining_offset,
|
||||
is_editing=True,
|
||||
)
|
||||
expected = []
|
||||
for cfg in GizmoDoorEdition.swing_arc_props:
|
||||
if cfg.visibility_condition(props):
|
||||
expected.append(
|
||||
(
|
||||
cfg.hinge_x(props),
|
||||
cfg.hinge_y(props),
|
||||
cfg.panel_width(props),
|
||||
cfg.x_mirror(props),
|
||||
)
|
||||
)
|
||||
return expected
|
||||
|
||||
|
||||
def test_visible_arcs_matches_gizmo_swing_arc_props_for_every_door_type():
|
||||
import bonsai.tool as tool
|
||||
|
||||
overall_width, lining_offset = 0.9, 0.05
|
||||
for door_type in get_args(tool.Model.DoorType):
|
||||
expected = _gizmo_expected(door_type, overall_width, lining_offset)
|
||||
actual = _arcs(door_type, overall_width, lining_offset)
|
||||
actual_tuples = [(a.hinge_x, a.hinge_y, a.panel_width, a.x_mirror) for a in actual]
|
||||
assert actual_tuples == expected, (
|
||||
f"Readonly decorator drifted from edit-mode gizmo for {door_type!r}: "
|
||||
f"expected {expected}, got {actual_tuples}"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Decorator gating contract (draw() early-returns)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_decorator_stub():
|
||||
"""Build a fresh ``DoorSwingReadonlyDecorator`` instance without going
|
||||
through ``install`` (which would attach a draw handler)."""
|
||||
from bonsai.bim.module.model.decorator import DoorSwingReadonlyDecorator
|
||||
|
||||
return DoorSwingReadonlyDecorator()
|
||||
|
||||
|
||||
def _draw_with_active(decorator, active_obj):
|
||||
"""Call ``draw`` with a minimal ``context`` stub."""
|
||||
ctx = SimpleNamespace(active_object=active_obj)
|
||||
decorator.draw(ctx)
|
||||
|
||||
|
||||
def test_draw_early_returns_when_no_active_object():
|
||||
# Should not raise; nothing to draw.
|
||||
_draw_with_active(_make_decorator_stub(), None)
|
||||
|
||||
|
||||
def test_draw_early_returns_when_active_not_selected():
|
||||
obj = SimpleNamespace(select_get=lambda: False)
|
||||
_draw_with_active(_make_decorator_stub(), obj)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user