mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3409c656ca | |||
| 66fcad84e3 | |||
| 913c9c42ba | |||
| 11a1af2f06 | |||
| 4ab04d4926 | |||
| af2c6ee9ac | |||
| 20645f43a5 | |||
| 84750765ab | |||
| 6d65c06160 | |||
| 826438a61f | |||
| 3c890f7537 | |||
| a31de52f3c | |||
| d71b3de87c | |||
| bea4f2c364 | |||
| f58875228d | |||
| e5116732d0 | |||
| 565414cf51 | |||
| 4dd82cad9b | |||
| 12a374dfb0 | |||
| 402b3f553c | |||
| 3f67620cc5 | |||
| d37ff6fe83 | |||
| 47f89373f0 | |||
| 7ab7f02db2 |
@@ -1,155 +0,0 @@
|
||||
# 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
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uv tool install ruff
|
||||
uv tool install black
|
||||
uv tool install poethepoet
|
||||
uv tool install ty==0.0.34
|
||||
uv tool install ty
|
||||
|
||||
# black doesn't catch all syntax errors, so we check them explicitly.
|
||||
- name: Check syntax errors
|
||||
|
||||
@@ -10,13 +10,9 @@ on:
|
||||
- 'src/ifcgeomserver/**'
|
||||
- 'src/ifcjni/**'
|
||||
- 'src/ifcmax/**'
|
||||
- 'src/ifc5d/**'
|
||||
- 'src/ifcedit/**'
|
||||
- 'src/ifcmcp/**'
|
||||
- 'src/ifcopenshell-python/**'
|
||||
- '!src/ifcopenshell-python/docs/**'
|
||||
- 'src/ifcparse/**'
|
||||
- 'src/ifcquery/**'
|
||||
- 'src/ifcwrap/**'
|
||||
- 'src/qtviewer/**'
|
||||
- 'src/svgfill/**'
|
||||
@@ -55,7 +51,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing psutil
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
|
||||
pip install src/bcf --no-deps
|
||||
pip install pytest-xdist==3.8.0
|
||||
|
||||
@@ -256,26 +252,13 @@ jobs:
|
||||
pip install deepdiff
|
||||
cd ../ifcdiff && make test || ERROR=1
|
||||
cd ../ifcpatch && make test || ERROR=1
|
||||
pip install -e ../ifc5d --no-deps
|
||||
pip install odfpy xlsxwriter
|
||||
cd ../ifc5d && make test || ERROR=1
|
||||
pip install -e ../ifcquery --no-deps
|
||||
cd ../ifcquery && make test || ERROR=1
|
||||
pip install -e ../ifcedit --no-deps
|
||||
cd ../ifcedit && make test || ERROR=1
|
||||
pip install mcp
|
||||
pip install -e ../ifcmcp --no-deps
|
||||
cd ../ifcmcp && make test || ERROR=1
|
||||
pip install -e ../ifctester --no-deps
|
||||
cd ../ifctester && make test || ERROR=1
|
||||
make build-ids-docs || ERROR=1
|
||||
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
|
||||
# mathutils only has pre-built wheels for Python 3.13+; skip on older versions.
|
||||
cd ../ifcopenshell-python
|
||||
if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)"; then
|
||||
pip install mathutils
|
||||
make test-mathutils || ERROR=1
|
||||
fi
|
||||
pip install mathutils
|
||||
make test-mathutils || ERROR=1
|
||||
if [ $ERROR -ne 0 ]; then
|
||||
echo "One or more tests failed";
|
||||
exit 1;
|
||||
|
||||
@@ -28,7 +28,6 @@ venv
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
.vs
|
||||
/*.code-workspace
|
||||
|
||||
# PyCharm files
|
||||
.idea
|
||||
@@ -129,4 +128,3 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
||||
*.claude
|
||||
*.py.tmp*
|
||||
*.json.tmp*
|
||||
|
||||
|
||||
+10
-20
@@ -27,14 +27,13 @@ endif()
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON) # not necessary, but encouraged
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# The VERSION file in the repository root is the single source of truth for the
|
||||
# release version. Read it unconditionally so a plain source build reports the
|
||||
# real version through buildinfo.cpp instead of the stale hardcoded 0.8.0
|
||||
# fallback (see #8164). VERSION_OVERRIDE still controls the branch name used
|
||||
# when ADD_COMMIT_SHA embeds a commit sha.
|
||||
file(READ "../VERSION" "RELEASE_VERSION_")
|
||||
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
|
||||
message(STATUS "Detected version '${RELEASE_VERSION}'")
|
||||
if(VERSION_OVERRIDE)
|
||||
file(READ "../VERSION" "RELEASE_VERSION_")
|
||||
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
|
||||
message(STATUS "Detected version '${RELEASE_VERSION}'")
|
||||
else()
|
||||
set(RELEASE_VERSION "0.8.0")
|
||||
endif()
|
||||
|
||||
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
|
||||
|
||||
@@ -259,14 +258,10 @@ 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.
|
||||
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()
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
|
||||
if(WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
@@ -661,11 +656,6 @@ if(ADD_COMMIT_SHA)
|
||||
endif()
|
||||
endif(ADD_COMMIT_SHA)
|
||||
|
||||
# Always expose the release version (from the VERSION file) to buildinfo.cpp so
|
||||
# that a build without commit-sha info reports the correct version instead of a
|
||||
# stale hardcoded fallback. See #8164.
|
||||
target_compile_definitions(IfcParse PRIVATE IFCOPENSHELL_VERSION_STRING=${RELEASE_VERSION})
|
||||
|
||||
if(MSVC)
|
||||
# @todo still needs to be understood better, but the cgal and cgal-simple kernel cause multiply defined boost lambda placeholders _1 ... _3
|
||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /FORCE:MULTIPLE")
|
||||
|
||||
@@ -88,15 +88,7 @@ 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}'.")
|
||||
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()
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
else()
|
||||
# If it failed, still try to find as a module.
|
||||
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# 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,26 +7,12 @@ 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(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_USE_STATIC_LIBS ON)
|
||||
set(Boost_USE_STATIC_RUNTIME OFF)
|
||||
set(Boost_USE_MULTITHREADED ON)
|
||||
set(Boost_COMPONENTS
|
||||
system
|
||||
program_options
|
||||
@@ -57,33 +43,13 @@ if(IFCOPENSHELL_WITH_ROCKSDB)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_IFCXML)
|
||||
find_dependency(LibXml2)
|
||||
find_dependency(LibXml2 CONFIG)
|
||||
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,400 +0,0 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Linked file features — queries, styles, transforms, and multi-linking for linked IFC models
|
||||
|
||||
> **Living dev note** for the `Linked_File_Features` branch/PR. Read before working
|
||||
> on the feature; append decisions and findings as the PR is refined. This is *not* user
|
||||
> documentation — at merge it is removed or its durable parts promoted to code comments.
|
||||
> See [README.md](README.md) for the convention (introduced on the
|
||||
> `opening-template-on-type` branch; not yet on this branch's base).
|
||||
|
||||
## Problem
|
||||
|
||||
Linked IFC models (`bim.link_ifc`) had several gaps that made them hard to use as a
|
||||
"reference in other trades' models" workflow:
|
||||
|
||||
- One shared `.ifc.cache.blend` per IFC file meant the **same file could not be linked
|
||||
twice with different selector queries** — both links showed whichever query was cached
|
||||
first in-session, and whichever was cached last after reopening (Blender reuses one
|
||||
library datablock per path).
|
||||
- The selector query was not durably stored anywhere in the host IFC, so save → reopen
|
||||
lost or cross-wired the filter; a scripted `bpy.ops.bim.reload_link()` also wiped it.
|
||||
- Linked geometry got **flat diffuse-only materials** — external `.blend` styles
|
||||
(`IfcExternallyDefinedSurfaceStyle`) and per-layer materials (layerset slicing) that
|
||||
the normal import applies were ignored.
|
||||
- Moving a linked model required an explicit enable-edit → move → save dance on the
|
||||
active link only, with save/cancel buttons in the panel header.
|
||||
- The Explore tool's highlight broke (GPU type errors), drew at the link's *original*
|
||||
location when the link had been moved, and `bim.append_inspected_linked_element`
|
||||
placed appended elements at the original location too.
|
||||
|
||||
## Key facts established
|
||||
|
||||
- **Cache architecture**: `LoadLink.link_ifc` generates a Python script and runs a
|
||||
background Blender subprocess that executes `bim.load_linked_project` and saves a
|
||||
`.ifc.cache.blend`. The host session then *links* (not appends) the `IfcProject/...`
|
||||
collection from that blend and instances it via an empty (the link "handle").
|
||||
Georeferencing metadata lives in a sidecar `.cache.json`; extracted properties in
|
||||
`.cache.sqlite` (whole file, query-independent — deliberately shared across queries).
|
||||
- **Blender reuses an in-session library per path.** Loading the same blend path twice
|
||||
yields the same library/collection. This is what broke multi-query linking with a
|
||||
shared cache filename, and why per-query *filenames* (not cache invalidation) are the
|
||||
fix.
|
||||
- **Last-used operator properties** are reused on the next *interactive* invocation
|
||||
(UI button), while scripted `bpy.ops` calls always start from defaults. LoadLink's
|
||||
internal `self.query = link.query` fallback assignment was remembered by Blender and
|
||||
leaked into the next button click (`operator_query='IfcWindow'` for the door link).
|
||||
Any `is_property_set()`-based logic is corrupted the same way. Fix: `SKIP_SAVE` on
|
||||
volatile props. **A GUI-only bug like this is invisible to scripted repro** — both
|
||||
headless and windowed `--python` test runs passed while the manual flow failed.
|
||||
- **`IfcDocumentReference`** per link: attribute index 1 (`Identification`) already
|
||||
stores the link's 4×4 transformation (existing Bonsai convention). `Description`
|
||||
(IFC4+; **absent in IFC2X3**) now stores the selector query. One
|
||||
`IfcDocumentInformation` (Scope `LINKED_MODEL`) per file, one reference per link.
|
||||
- **Geometry iterator materials**: `material.instance_id()` is the STEP id of the
|
||||
`IfcSurfaceStyle` — or of an `IfcMaterial` when the item has a material but no style,
|
||||
hence the `is_a("IfcSurfaceStyle")` guard when resolving external styles.
|
||||
- **External styles**: `IfcExternallyDefinedSurfaceStyle.Location` (`.blend`, relative
|
||||
paths resolve against the *linked* IFC, not the host) + `Identification` in
|
||||
`data_block_type/name` form (e.g. `materials/Brick`), same convention as
|
||||
`bim.activate_external_style`.
|
||||
- **Chunk pipeline dedups materials by RGBA color** (`np.unique` on a color array), so
|
||||
style identity must ride along as an extra column to survive — added only for styles
|
||||
that actually resolve to an external material, so plain colored styles dedupe exactly
|
||||
as before.
|
||||
- **`slice_layerset_mesh` needs a local-space, per-element mesh** (bisect planes are in
|
||||
object space), which the chunk path can't provide (world-space, many elements per
|
||||
mesh) — hence routing multi-layer elements through the instanced path. Its
|
||||
`dissolve_limit` produces **ngons**, which broke the Explore highlight's
|
||||
triangles-from-`polygon.vertices` assumption downstream.
|
||||
- **ID properties round-trip as `IDPropertyArray`**, not plain lists (verified in
|
||||
4.5.7: empty list → flat `IDPropertyArray`; nested lists → list of `IDPropertyArray`
|
||||
items), and `GPUIndexBuf` rejects them — selection geometry must be converted to
|
||||
plain tuples on read.
|
||||
- **`scene.ray_cast` returns the hit instance's world matrix** (link empty matrix
|
||||
included). For instanced occurrence objects the object's own local matrix is *not*
|
||||
identity, so resolving the instancing empty must compare against
|
||||
`empty.matrix_world @ obj.matrix_world`, not the empty's matrix alone.
|
||||
- **Link matrix math**: the handle empty's matrix is `inv(L) @ T @ G` (L = host local
|
||||
matrix from georef props, T = stored transformation, G = linked model's global
|
||||
matrix from the cache json). The world-space displacement of a moved link is
|
||||
therefore `inv(L) @ T @ L` — no json read needed (`calculate_link_delta_matrix`).
|
||||
- **Undo consistency of auto-saved moves**: Blender undo of a handle move fires another
|
||||
depsgraph update, so the handler re-saves the reverted matrix — stored state stays
|
||||
consistent without transactions (a handler can't open one).
|
||||
|
||||
## Design
|
||||
|
||||
### Per-query caches + query persistence (multi-linking)
|
||||
|
||||
`tool.Project.get_link_cache_paths(filepath, query)` appends `.md5(query)[:8]` to the
|
||||
cache blend/json names; the empty query keeps the legacy un-suffixed names so existing
|
||||
caches stay valid. Every cache-path consumer goes through it — `link_ifc` build and
|
||||
invalidation, the subprocess json write, model-origin/georef indicator reads,
|
||||
`calculate_link_matrix`, `save_link_transformation`, and the per-link
|
||||
selectability/wireframe/visibility toggles (which match collections *by library
|
||||
filepath* and would otherwise affect every link of the file at once).
|
||||
|
||||
The query persists on each link's `IfcDocumentReference.Description` (written by
|
||||
`LinkIfc` and `ReloadLink`); `load_linked_models_from_ifc` restores from it, with a
|
||||
legacy-JSON fallback that only applies when the file has a **single** link (with
|
||||
several links the shared JSON can't say which link it belonged to). IFC2X3 hosts have
|
||||
no `Description` — custom queries are not restorable there (accepted).
|
||||
|
||||
`LoadLink`/`ReloadLink` volatile properties are `SKIP_SAVE` (see key facts). Cache
|
||||
clearing tolerates a missing blend (a reload with a brand-new query points at a
|
||||
not-yet-existing filename).
|
||||
|
||||
### Include/Exclude filter pair
|
||||
|
||||
The selector grammar's only cross-group combiner is `+` (union) and the `parent`
|
||||
facet cannot express "not under X" (its `!=`/regex paths also match by GlobalId, so
|
||||
negation removes everything with any parent), which makes set differences like
|
||||
"group members minus the slabs under aggregate X" structurally inexpressible in one
|
||||
query string. Links therefore carry an **Exclude** query beside the include —
|
||||
mirroring `EPset_Drawing`'s Include/Exclude pattern: final set = include (or the
|
||||
default set when empty) − exclude, applied in `LoadLinkedProject` and per link in
|
||||
`create_drawing`.
|
||||
|
||||
- **Cache key**: `get_link_cache_paths` hashes `md5(query + "\0" + exclude)` when an
|
||||
exclude exists; include-only filters keep the pre-exclude `md5(query)` so existing
|
||||
caches stay valid; empty filter keeps legacy un-suffixed names. Keying on query
|
||||
alone would let same-include/different-exclude links silently serve each other's
|
||||
geometry.
|
||||
- **Persistence**: `encode_link_filter`/`decode_link_filter` — a plain include is
|
||||
stored in `Description` as-is (backwards compatible); an exclude, a `loaded`
|
||||
state or a custom display name promotes the value to
|
||||
`{"include": …, "exclude": …, "loaded": …, "name": …}` JSON. Decode treats
|
||||
non-JSON as a legacy include string. The display name (`Link.display_name`,
|
||||
double-click the list row to rename; file path shows as placeholder while
|
||||
unset) exists to tell apart several links of the same file.
|
||||
- Exclude applies on top of the **default** element set too, so
|
||||
"everything except X" needs no explicit include.
|
||||
- UI labels are **Include**/**Exclude** (matching the drawing pattern), but the
|
||||
property identifier stays `query` for script (`bpy.ops.bim.link_ifc(query=…)`)
|
||||
and persistence compatibility.
|
||||
- Verified headless: `query=""`/`exclude="IfcDoor"` loads only the window;
|
||||
same file with a different filter gets its own cache; both filters survive
|
||||
save → reopen → reload.
|
||||
|
||||
### Auto-load on open
|
||||
|
||||
Links that were **loaded and visible** at IFC save time auto-load when the project
|
||||
is reopened. `ExportIFC` calls `tool.Project.update_linked_models_state()`, which
|
||||
rewrites each reference's `Description` with a `loaded` flag
|
||||
(`is_loaded and not is_hidden`); `load_linked_models_from_ifc` replays flagged
|
||||
links via `load_link` after restoring the list (missing files warn and skip so
|
||||
they can't break project open). The flag extends the same JSON blob as the
|
||||
exclude — plain legacy strings decode as no-autoload. Trade-off: project open
|
||||
pays the link-load cost up front (fast on cache hit; a missing cache rebuilds in
|
||||
a background Blender, same as clicking Load). Verified headless: loaded+visible
|
||||
auto-loads; unloaded and loaded-but-hidden links stay unloaded.
|
||||
|
||||
### Long-term serialization target: STEP Part 21 Edition 3
|
||||
|
||||
STEP p21e3 defines the standards-track version of this feature's persistence:
|
||||
`ANCHOR`/`REFERENCE` sections (clauses 9–10) let one file import entities from
|
||||
another via URI + fragment, and **anchor tags** (`{tagname: value}`) are the
|
||||
designated slot for out-of-schema metadata — a cleaner home than the
|
||||
`Description` JSON blob (see the review-round discussion). ifcopenshell does not
|
||||
implement these sections yet ([#668](https://github.com/IfcOpenShell/IfcOpenShell/issues/668),
|
||||
open, unassigned); if it ever does, the migration path is: link →
|
||||
`REFERENCE` to the linked file's project anchor, filter/transform/loaded
|
||||
metadata → anchor tags. Keeping the blob behind
|
||||
`encode_link_filter`/`decode_link_filter` makes that a two-function change.
|
||||
|
||||
Two p21e3 design points this branch already conforms to:
|
||||
|
||||
- **Identity**: p21e3 distinguishes volatile file-scoped entity numbers
|
||||
(`#100` fragments) from durable anchors/UUIDs — the same lesson behind our
|
||||
STEP-id collision fixes (GUID-based matching, `element.file` guards). Raw
|
||||
STEP ids must never cross a file boundary; IFC GlobalIds map 1:1 onto
|
||||
p21e3 UUID anchors.
|
||||
- **Transport** (clause A.4): exchange structures plus referenced resources
|
||||
can ship as one ZIP archive with references resolving inside it. Our posix,
|
||||
optionally relative `Location`s resolved via `resolve_uri` are exactly the
|
||||
invariants a future "package project with links" export would need.
|
||||
|
||||
Even full p21e3 support would not cover per-link transforms, filters, or load
|
||||
state — a `REFERENCE` imports entities, it does not place a model — so the
|
||||
app-level metadata remains; only its container would change.
|
||||
|
||||
### External styles + layerset slicing in the linked loader
|
||||
|
||||
`LoadLinkedProject.get_external_material(style_id)` resolves a style id → appended
|
||||
Blender material from the external `.blend`, cached two ways (per style id; per
|
||||
appended data-block, so styles sharing one material don't append duplicates). Appended
|
||||
materials get their stale `ifc_definition_id` cleared (the source `.blend` may have
|
||||
been authored in a Bonsai session; the id would be misread in the linked file *and*
|
||||
in the host once the cache links in). Applied in both loading paths — instanced
|
||||
occurrences directly, chunks via the style-id column.
|
||||
|
||||
Multi-layer elements (`IfcMaterialLayerSetUsage`, >1 layer) route through the
|
||||
instanced path and get `slice_layerset_mesh`, which gained a pluggable
|
||||
`style_to_material` resolver (defaults to the old `tool.Ifc.get_object` for the normal
|
||||
import) — the linked resolver prefers the external material, falling back to a flat
|
||||
diffuse from the style's shading colour. Also fixed there: newly appended layer
|
||||
materials are registered in the dedup dict (two layers sharing one style used to
|
||||
append it twice).
|
||||
|
||||
Trade-off: layered walls become individual instanced objects instead of chunk members;
|
||||
meshes shared between elements (same geometry id) bake the slice from the first
|
||||
element's layerset usage — same behaviour as the normal importer.
|
||||
|
||||
### Reload Link dialog
|
||||
|
||||
`bim.reload_link` now exposes File Path (+ browse button), Use Relative Path
|
||||
(defaulting to the stored path form), Use Cache (default off = old always-rebuild
|
||||
behaviour), the False Origin Mode project props, and Query. A file browser can't open
|
||||
from inside a props dialog, so the browse button runs `bim.select_link_filepath`
|
||||
(fileselect) which *reopens* the reload dialog with the chosen path, carrying the
|
||||
in-progress dialog state through the round trip (op props are baked at draw time).
|
||||
Path changes update `link.name`/`filepath` and, with a host IFC, the reference
|
||||
`Location` + document name — which is why `ReloadLink` became a `tool.Ifc.Operator`.
|
||||
Script calls without arguments preserve all stored link values via `is_property_set`.
|
||||
|
||||
`bim.reload_all_links` (refresh button beside Link IFC in the panel header) reloads
|
||||
every *loaded* link via argument-less `reload_link` calls — each link's stored
|
||||
path/query/exclude replay and its cache rebuilds from disk. Unloaded links are left
|
||||
alone. Deliberately expensive: one background cache rebuild per link.
|
||||
|
||||
### Per-row lock toggle + auto-saved transforms
|
||||
|
||||
Link editing moved from the panel header into each list row as a lock/unlock icon:
|
||||
unlock (`bim.enable_editing_link`) frees the handle; **any movement is persisted
|
||||
immediately** by a `depsgraph_update_post` handler (lazy — ticks without transform
|
||||
updates cost ~nothing); lock (`bim.disable_editing_link`) saves and locks.
|
||||
`bim.edit_link` and the explicit save step are **removed**; cancel/restore semantics
|
||||
no longer exist (undo or move it back). The save math lives in
|
||||
`tool.Project.save_link_transformation`. Enable/disable take a `link_index`
|
||||
(default −1 = active link) so several links can be edited at once and script calls
|
||||
stay compatible.
|
||||
|
||||
### Explore tool + append fixes for moved links
|
||||
|
||||
- Highlight triangles come from `mesh.calc_loop_triangles()` filtered to the queried
|
||||
element's polygon range (ngon-safe); edges keep `polygon.edge_keys` (no diagonals).
|
||||
- `get_selected_geometry` converts the ID-prop round trip to plain tuples (GPU
|
||||
rejects `IDPropertyArray`); TRIS drawing gated on its own data.
|
||||
- `QueryLinkedElement` passes the ray-cast instance matrix through;
|
||||
`find_obj_root` compares it against `empty @ obj_local` and falls back to the
|
||||
collection's only instance when no matrix is available (select-by-GUID flow).
|
||||
- `bim.append_inspected_linked_element` pre-multiplies the imported object's matrix by
|
||||
`calculate_link_delta_matrix(link)`, matching the link by the queried instance's
|
||||
root empty first (filepath alone is ambiguous with several links per file). The
|
||||
element's IFC placement syncs to the moved location on save — intended.
|
||||
|
||||
### Drawings (`create_drawing`) — moved links and per-link queries
|
||||
|
||||
- The linework serializer opened linked IFCs raw, so a moved link's elements were
|
||||
drawn at their *original* coordinates (usually outside the drawing extents —
|
||||
"linked objects disappear from prints after moving the link").
|
||||
- The stored link transformation is already the **model-space** delta (that is how
|
||||
`save_link_transformation` derives it), which is exactly the space the serializer
|
||||
works in — so it can be baked straight into the geometry iterator via the existing
|
||||
`model-offset`/`model-rotation` settings. The mapping composes
|
||||
`Trans(model-offset) @ Rot(model-rotation)` (see `mapping.cpp`), matching the
|
||||
`Trans(t) @ Rot(R)` decomposition of the rigid link matrix; `model-rotation` is a
|
||||
quaternion passed as `(x, y, z, w)`. The pre-existing 2mm plan-view Z-offset simply
|
||||
adds onto the translation (translations commute).
|
||||
- The serialization loop previously collected files in a dict keyed by filepath, which
|
||||
**collapsed same-file links into one pass** (one transform — the last link's — and
|
||||
no query awareness): with two links of one file, only one showed in the drawing.
|
||||
It now iterates one entry per link (`(path, file, transform, query)` tuples), and
|
||||
intersects each link's drawing elements with
|
||||
`ifcopenshell.util.selector.filter_elements(ifc, link.query)` so the drawing shows
|
||||
what that link actually displays in the viewport.
|
||||
- `tool.Project.get_link_transformation_matrix(link)` is the shared accessor for the
|
||||
stored 4×4 (None when identity/absent).
|
||||
- Verified headless with the window/door kit: moved window offset in the SVG by
|
||||
exactly 5m × scale; unmoved door at its native position; both links present.
|
||||
|
||||
### Drawings — `.cut` styling for linked models (BISECT cut mode)
|
||||
|
||||
- The default **BISECT** cut mode deletes the OpenCASCADE serializer's cut linework
|
||||
(`remove_cut_linework`) and regenerates cuts by bisecting **Blender mesh objects**
|
||||
(`generate_bisect_linework` over `context.visible_objects`). Linked models are
|
||||
instanced collections with no mesh objects, so their cuts were deleted and never
|
||||
regenerated — linked elements only ever appeared as `projection`, and the `.cut`
|
||||
CSS rule never applied to them. Long-standing gap, unrelated to moved links
|
||||
(A/B-tested against pre-branch code: identical).
|
||||
- Fix: `remove_cut_linework` only removes cut groups whose guid resolves in the
|
||||
**host** file — linked elements keep the serializer's cut geometry, which the
|
||||
merge step then classes as `cut`.
|
||||
- **Cross-file STEP-id collision**: `tool.Ifc.get_object(linked_entity)` resolves the
|
||||
entity's STEP id against the *host* session's id map and can return an arbitrary
|
||||
host object (in the test project: the drawing camera, crashing
|
||||
`generate_material_layers` with "expected 'Mesh' found 'Camera'"). Guarded via
|
||||
`element.file is tool.Ifc.get()` in `generate_material_layers` and the merge step.
|
||||
- **Paint order**: the projection-under-cut convention was enforced only in
|
||||
OPENCASCADE mode (`move_projection_to_bottom`); BISECT appends its own cut paths
|
||||
last so it never needed it — but the retained serializer cuts of linked models are
|
||||
emitted *before* the projections. BISECT now runs the same pass; `BringToFront`
|
||||
(`move_elements_to_top`) still gets the final say.
|
||||
- Known limitation: linked cut paths are raw serializer output — they skip the
|
||||
shapely path-closing/merging and the material-layer hatching pass (both need host
|
||||
Blender objects). Stroke + fill from `.cut` CSS apply; layered hatching inside
|
||||
linked cuts is a candidate follow-up.
|
||||
- Debugging note: merged cut groups carry member guids as CSS *classes*, not as the
|
||||
`ifcopenshell:guid` attribute — inspect both when checking cut output.
|
||||
|
||||
## Deferred refactors (deliberate)
|
||||
|
||||
- **Upstream `exclude=` on `filter_elements`** — the include−exclude set difference
|
||||
is hand-rolled twice (links, drawings) because the selector grammar has no
|
||||
difference operator and `parent` negation is broken by design (its `!=`/regex
|
||||
paths also match GlobalIds, so negation strips everything that has a parent).
|
||||
The right home is an `exclude=` parameter on
|
||||
`ifcopenshell.util.selector.filter_elements`, documented in
|
||||
`selector_syntax.rst` together with the `parent`-negation limitation. Deferred
|
||||
to a separate ifcopenshell-python PR (different review audience; would widen
|
||||
this PR mid-review). Once it lands, both Bonsai call sites collapse.
|
||||
- **Core/tool ceremony skipped** — the new `tool.Project` methods have no
|
||||
`core/tool.py` interface declarations and no `bonsai/core` orchestration
|
||||
functions, matching the pre-existing linked-model code (which bypasses the
|
||||
core layer wholesale; `LoadLinkedProject` is flagged "prototyping" upstream).
|
||||
Interfaces nobody calls through wouldn't add testability — the pure helpers
|
||||
(`encode_link_filter`/`decode_link_filter`, `get_link_cache_paths`) are
|
||||
covered directly in `test/tool/test_project.py` instead. Revisit if the
|
||||
linked-model subsystem is ever promoted out of prototype status.
|
||||
|
||||
## Review round 1 (PR #8242, falken10vdl) — decisions
|
||||
|
||||
- **Path-form mismatch → duplicate documents (confirmed bug, fixed).**
|
||||
`get_linked_models_documents()` keyed documents by the *stored* `Location`, so
|
||||
linking the same file first relative then absolute (or vice versa) created a second
|
||||
`IfcDocumentInformation`. Both sides of the lookup now normalize through
|
||||
`tool.Ifc.resolve_uri()` before matching.
|
||||
- **`Description` for the query — kept.** It is implementation metadata in an IFC
|
||||
attribute, but consistent with the existing convention on these same references
|
||||
(`Identification` stores the 4×4 transformation, a bigger stretch). References are
|
||||
Bonsai-managed (`Scope="LINKED_MODEL"`), so user-description collisions are unlikely.
|
||||
A cleaner consolidated convention (query + transform + options in one serialized
|
||||
attribute) is a candidate follow-up, deliberately out of scope here.
|
||||
- **`md5(query)[:8]` — kept.** 32 bits ≈ birthday collision at ~65k distinct queries
|
||||
*per file*; and a collision is not silent: the cache JSON stores the full query and
|
||||
`should_clear_cache()` compares it, so a colliding cache is detected and rebuilt
|
||||
(self-healing).
|
||||
- **Depsgraph autosave vs save-on-lock — autosave kept.** Save-on-lock alone loses the
|
||||
"what you see is what's saved" guarantee (move + save project without locking =
|
||||
silently dropped move) and loses undo tracking (undo fires a depsgraph update that
|
||||
re-saves the reverted transform). The handler early-outs when no links exist and only
|
||||
works on ticks containing an object-transform update while a link is unlocked.
|
||||
|
||||
## Status — implemented (verified in Blender, incl. headless + GUI repro runs)
|
||||
|
||||
Six commits on `Linked_File_Features`:
|
||||
|
||||
- `0096c0f6a2` reload_link without a query preserves the stored one.
|
||||
- `40db55e52d` external styles + layerset slicing for linked models
|
||||
(`project/operator.py`, `tool/loader.py`).
|
||||
- `d210d4c814` full Reload Link dialog + `bim.select_link_filepath`.
|
||||
- `3dc161f0f2` per-row lock toggle, auto-save handler, `edit_link` removed
|
||||
(`project/operator.py`, `project/ui.py`, `project/__init__.py`, `tool/project.py`).
|
||||
- `0571d22855` Explore highlight (ngons, IDPropertyArray), moved-link highlight,
|
||||
append placement (`tool/project.py`, `project/operator.py`, `project/decorator.py`).
|
||||
- `c14592ec0a` per-query caches, Description persistence, SKIP_SAVE.
|
||||
|
||||
Plus:
|
||||
|
||||
- `ee43ed5526` review-round path normalization in `get_linked_models_documents` /
|
||||
`LinkIfc` (see Review round 1).
|
||||
- `1669cbcd43` drawing support for moved links and per-link queries in
|
||||
`create_drawing` (`drawing/operator.py`, `tool/project.py`).
|
||||
- `.cut` styling for linked models in BISECT cut mode + STEP-id collision guards +
|
||||
paint order (`drawing/operator.py`) — committed together with this note update.
|
||||
|
||||
End-to-end verified with a two-links-one-file kit (window/door, distinct queries):
|
||||
correct visuals on load, after save → reopen → reload, in both headless and windowed
|
||||
Blender.
|
||||
|
||||
## Things to test / verify
|
||||
|
||||
- **IFC2X3 host**: `Description` doesn't exist — link queries silently not restored on
|
||||
reopen (legacy fallback only for single-link files). Acceptable? Warn?
|
||||
- **Relative-path links** (`use_relative_path`) through the whole cycle: cache paths,
|
||||
reference `Location`, reload path change, query restore. The duplicate-document case
|
||||
(same file linked relative then absolute) is fixed — verify one document with two
|
||||
references via `IfcDocumentInformation.HasDocumentReferences`.
|
||||
- Same file linked twice, **both moved differently**: Explore highlight and append
|
||||
placement per instance (root-empty matching), per-link visibility toggles.
|
||||
- External styles with **image textures**: paths relative to the style's source
|
||||
`.blend` may not resolve from the cache blend's location (shared limitation with the
|
||||
normal import path).
|
||||
- Stale cache orphans: per-query filenames accumulate one blend+json pair per distinct
|
||||
query next to the IFC; nothing auto-deletes them. Cleanup on unlink? Document?
|
||||
- Mid-drag auto-save writes the IFC reference outside Bonsai's transaction system —
|
||||
confirm no undo-stack weirdness in longer editing sessions.
|
||||
- Layerset slicing on meshes shared by elements with *different* usages (offset/sense)
|
||||
bakes the first element's slice — same as normal import, but worth a look with types.
|
||||
- `bim.select_link_filepath` round trip when the reload dialog was opened for a
|
||||
non-active link, and dialog-state carry-over after editing the query *then* browsing.
|
||||
- **Drawing SVG guid cache vs moved links**: `create_drawing` skips elements whose
|
||||
guids already exist in the drawing's SVG (`cached_linework`, invalidated only for
|
||||
*edited host objects*). Moving a link does not invalidate its elements, so a
|
||||
regenerated drawing keeps their old positions until the SVG is deleted. Candidate
|
||||
fix: subtract a moved link's guids from `cached_linework` (compare stored transform
|
||||
against the one recorded at last generation).
|
||||
- Same element appearing in two links of one file (overlapping queries) serializes
|
||||
twice with different transforms; the SVG guid cache keeps whichever came first on
|
||||
regeneration. Degenerate case — probably fine to ignore, but note it.
|
||||
+2
-1
@@ -126,9 +126,10 @@ ssl._create_default_https_context = ssl._create_unverified_context
|
||||
import time
|
||||
from collections.abc import Generator, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Literal, Union
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
from typing import Literal, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
ch = logging.StreamHandler()
|
||||
|
||||
+2
-10
@@ -106,7 +106,7 @@ endif
|
||||
endif # def PLATFORM
|
||||
|
||||
# Current build commit hash.
|
||||
OLD:=3e7b739
|
||||
OLD:=1c5b825
|
||||
.PHONY: bump
|
||||
bump:
|
||||
ifndef NEW
|
||||
@@ -192,11 +192,7 @@ endif
|
||||
# Provides networkx graph analysis for project dependency calculations
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
|
||||
# Required by IFCDiff
|
||||
# Pinned <9.1: deepdiff 9.1.0 adds cachebox<6,>=5.2 which only ships macOS x86_64
|
||||
# wheels for macosx_10_12+ and is incompatible with our macos py311 --platform
|
||||
# macosx_10_10_x86_64 target. Revisit once the macos py311 platform tag is bumped
|
||||
# to 10_13 (matching py312/py313).
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download "deepdiff<9.1" --dest=./wheels
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
|
||||
# Required by IFCCSV and ifcopenshell.util.selector
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
|
||||
# Required by IFC4D
|
||||
@@ -360,10 +356,6 @@ else
|
||||
pytest test/tool/test_$(MODULE).py --maxfail=1
|
||||
endif
|
||||
|
||||
.PHONY: test-modal
|
||||
test-modal:
|
||||
blender --enable-event-simulate --python test/modal/test_modal.py --window-maximized
|
||||
|
||||
# Reregistering test is not added to the standard test suite because during unregister
|
||||
# Blender removes all Bonsai dependencies breaking dev-environment symlinks.
|
||||
.PHONY: test-reregister
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
#
|
||||
# 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 importlib
|
||||
import os
|
||||
@@ -27,7 +25,7 @@ import bpy
|
||||
import bpy.utils.previews
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from . import handler, operator, parametric_lifecycle, prop, ui
|
||||
from . import handler, operator, prop, ui
|
||||
|
||||
try:
|
||||
from bonsai.translations import translations_dict
|
||||
@@ -90,7 +88,6 @@ 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,
|
||||
@@ -160,6 +157,9 @@ classes = [
|
||||
ui.BIM_UL_tab_visibilities,
|
||||
ui.BIM_UL_panel_visibilities,
|
||||
ui.DocPreferences,
|
||||
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesStair, # Register before GizmoPreferences
|
||||
ui.GizmoPreferences,
|
||||
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
|
||||
# Tabs panel
|
||||
@@ -268,8 +268,6 @@ def register():
|
||||
bpy.app.handlers.depsgraph_update_post.append(on_register)
|
||||
bpy.app.handlers.undo_post.append(handler.undo_post)
|
||||
bpy.app.handlers.redo_post.append(handler.redo_post)
|
||||
# Must follow the two appends above so regenerators see restored IFC state.
|
||||
parametric_lifecycle.install_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
bpy.app.handlers.load_post.append(handler.loadIfcStore)
|
||||
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
|
||||
@@ -327,7 +325,6 @@ def unregister():
|
||||
|
||||
unregister_classes(classes)
|
||||
|
||||
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
|
||||
del bpy.types.Scene.BIMProperties
|
||||
|
||||
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
|
||||
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -33,7 +33,5 @@ DATA;
|
||||
#26=IFCSIMPLEPROPERTYTEMPLATE('2iwERDOW55Pf4hCbuFRe1Q',$,'FillMode','Method to fill areas seen in projection',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#27=IFCSIMPLEPROPERTYTEMPLATE('1YF$qLzBzF19Io8aB2N8cE',$,'CutMode','Method for cutting geometry',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -5,7 +5,7 @@ FILE_NAME('Psets_BBIM_Annotation.ifc','2020-01-01T00:00:00',$,$,'Psets_BBIM_Anno
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2));
|
||||
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2,#41,#42));
|
||||
#2=IFCSIMPLEPROPERTYTEMPLATE('2P7JN79n96Q9pElZ83LKe4',$,'ZIndex','',.P_SINGLEVALUE.,'IfcInteger',$,$,$,$,$,.READWRITE.);
|
||||
#3=IFCSIMPLEPROPERTYTEMPLATE('1Wpx_r2xj1_9w5JpI0QRJy',$,'Symbol','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#4=IFCSIMPLEPROPERTYTEMPLATE('3q0oxMUKP47vZ4jnyG$dDb',$,'Classes','Classes separated by spaces that end up in classes for this element in svg. Can be used to specify the text font size: small - 1.8mm; regular - 2.5mm; large - 3.5mm; header - 5mm; title - 7mm. By default regular size is used.',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -28,7 +28,7 @@ DATA;
|
||||
#21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30));
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39,#40));
|
||||
#25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -38,5 +38,14 @@ DATA;
|
||||
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
|
||||
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#40=IFCSIMPLEPROPERTYTEMPLATE('1LoNpKqR3sTuVwXyZaBcDe',$,'LinePosition','Absolute world-space coordinate (metres) of the dimension line along the horizontal offset axis (perpendicular to the dimension direction). When set, the dimension line is held at this fixed global position even if the measured geometry moves. When absent the line sits at the anchor points.',.P_SINGLEVALUE.,'IfcLengthMeasure',$,$,$,$,$,.READWRITE.);
|
||||
#41=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsAnnotFaux0001aB',$,'IsManualDrawingReference','Marks this annotation as a manually placed drawing reference, exempt from automatic drawing regeneration.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#42=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsDocRefFaux001aB',$,'IsDocumentReference','Marks this annotation as pointing to an external document reference (not a Bonsai drawing camera).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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 structural-change cache token for POST_VIEW decorators.
|
||||
|
||||
Decorators include the token in their cache key and rebuild on bump."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
import bpy
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DECORATOR_CACHE_TOKEN = 0
|
||||
|
||||
|
||||
def get_decorator_cache_token() -> int:
|
||||
return _DECORATOR_CACHE_TOKEN
|
||||
|
||||
|
||||
def reset_for_test() -> None:
|
||||
"""Test-only: reset the cache token to 0 so bump-count assertions are stable."""
|
||||
global _DECORATOR_CACHE_TOKEN
|
||||
_DECORATOR_CACHE_TOKEN = 0
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _bump_decorator_cache_token(*args: Any) -> None:
|
||||
"""depsgraph_update_post fires every animation frame and every driver
|
||||
evaluation, even when no IFC-relevant ID block changed. Unconditional
|
||||
bumping defeats the cache: an animated scene rebuilds every decorator
|
||||
every viewport tick. Gate the depsgraph path on Object geometry or
|
||||
transform updates; undo / redo / load have no depsgraph and always
|
||||
invalidate.
|
||||
|
||||
Coverage assumption: ``TokenCache`` consumers key on Object identity
|
||||
(depsgraph updates whose ``id`` is a ``bpy.types.Object``). Mesh /
|
||||
Material / NodeTree updates that don't surface as an Object change
|
||||
do NOT invalidate the token — a decorator that caches material- or
|
||||
mesh-data-derived state must gate on a separate signal."""
|
||||
global _DECORATOR_CACHE_TOKEN
|
||||
if len(args) >= 2:
|
||||
depsgraph = args[1]
|
||||
if depsgraph is not None and hasattr(depsgraph, "updates"):
|
||||
if not any(
|
||||
(getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False))
|
||||
and hasattr(u, "id")
|
||||
and isinstance(u.id, bpy.types.Object)
|
||||
for u in depsgraph.updates
|
||||
):
|
||||
return
|
||||
_DECORATOR_CACHE_TOKEN += 1
|
||||
|
||||
|
||||
def _hooks() -> tuple[Any, ...]:
|
||||
return (
|
||||
bpy.app.handlers.depsgraph_update_post,
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
|
||||
def install_decorator_cache_handlers() -> None:
|
||||
"""Append the bump handler to each hook; idempotent."""
|
||||
for hook in _hooks():
|
||||
if _bump_decorator_cache_token not in hook:
|
||||
hook.append(_bump_decorator_cache_token)
|
||||
|
||||
|
||||
def uninstall_decorator_cache_handlers() -> None:
|
||||
for hook in _hooks():
|
||||
try:
|
||||
hook.remove(_bump_decorator_cache_token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
class TokenCache(Generic[T]):
|
||||
"""Memoise a single value keyed on ``(caller_key, get_decorator_cache_token())``.
|
||||
|
||||
The token component invalidates the cache on depsgraph / undo / redo / load,
|
||||
so cached ``bpy.types.Object`` references can't outlive the underlying ID
|
||||
blocks. Holds exactly one entry — last key wins."""
|
||||
|
||||
__slots__ = ("_key", "_value")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._key: tuple[Any, int] | None = None
|
||||
self._value: T | None = None
|
||||
|
||||
def get_or_compute(self, key: Any, compute: Callable[[], T]) -> T:
|
||||
token_key = (key, _DECORATOR_CACHE_TOKEN)
|
||||
if token_key == self._key:
|
||||
return self._value # type: ignore[return-value]
|
||||
value = compute()
|
||||
self._key = token_key
|
||||
self._value = value
|
||||
return value
|
||||
@@ -15,12 +15,11 @@
|
||||
#
|
||||
# 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 os
|
||||
import weakref
|
||||
from collections.abc import Callable
|
||||
from math import cos
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
@@ -32,32 +31,16 @@ from bpy.app.handlers import persistent
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.bim
|
||||
import bonsai.core.model as core_model
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.decorator_cache import (
|
||||
install_decorator_cache_handlers,
|
||||
uninstall_decorator_cache_handlers,
|
||||
)
|
||||
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
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.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -125,13 +108,19 @@ def active_object_callback():
|
||||
|
||||
|
||||
def update_bim_tool_props():
|
||||
"""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:
|
||||
"""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:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
aprops = tool.Drawing.get_annotation_props()
|
||||
@@ -144,85 +133,18 @@ def update_bim_tool_props():
|
||||
|
||||
if is_annotation_tool and (object_type := tool.Drawing.get_annotation_type_object_type(element_type)):
|
||||
aprops.object_type = object_type
|
||||
try:
|
||||
aprops.relating_type_id = str(element_type.id())
|
||||
except TypeError:
|
||||
# EnumProperty items are rebuilt asynchronously when ifc_class changes;
|
||||
# this assignment can race a stale item list. Skipping is harmless —
|
||||
# the UI will resync on the next active_object_callback.
|
||||
pass
|
||||
aprops.relating_type_id = str(element_type.id())
|
||||
return
|
||||
|
||||
if is_bim_tool:
|
||||
try:
|
||||
props.ifc_class = element_type.is_a()
|
||||
except TypeError:
|
||||
# ifc_class only lists element/space types present in the model, so an
|
||||
# unsupported type (e.g. a raw IfcTypeProduct) or a stale item list mid-
|
||||
# rebuild raises `enum "<class>" not found`. Skip rather than crash the
|
||||
# handler — it re-fires on the next selection and the panel resyncs.
|
||||
pass
|
||||
props.ifc_class = element_type.is_a()
|
||||
|
||||
# Only assign when the target enum is the one that lists this type — otherwise
|
||||
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
|
||||
# different class than the workspace tool was built for (e.g. selecting a wall
|
||||
# while the door tool is active).
|
||||
tool_class_match = TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a()
|
||||
bim_tool_class_match = is_bim_tool and props.ifc_class == element_type.is_a()
|
||||
if bim_tool_class_match or tool_class_match:
|
||||
try:
|
||||
props.relating_type_id = str(element_type.id())
|
||||
except TypeError:
|
||||
# Defensive: the enum item list can lag behind ifc_class assignment
|
||||
# above. Skipping leaves the panel briefly out of sync rather than
|
||||
# crashing the handler (which Blender re-fires on every selection).
|
||||
pass
|
||||
if is_bim_tool or TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a():
|
||||
props.relating_type_id = str(element_type.id())
|
||||
|
||||
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
|
||||
@@ -240,13 +162,10 @@ def _read_headers_into_props(obj, element):
|
||||
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"]
|
||||
props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
|
||||
extrusion.Depth * si_conversion, x_angle
|
||||
)
|
||||
props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle))
|
||||
props.length = (axis[1] - axis[0]).length
|
||||
props.x_angle = x_angle
|
||||
|
||||
@@ -437,10 +356,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, load-transient parametric state, and the
|
||||
multi-instance lock probe."""
|
||||
@persistent
|
||||
def load_post(scene):
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -451,23 +368,6 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
ifcopenshell.api.owner.settings.get_application = get_application
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
tool.Parametric.on_load_post(scene)
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
props.has_blend_warning = True
|
||||
|
||||
# Probe the H5 cooked-geometry cache so the multi-instance warning surfaces
|
||||
# right after .blend load. Without this, the lock is only detected when a
|
||||
# mutation triggers ``clear_cache`` — by which time the user has already
|
||||
# made changes that may now conflict with the other Blender instance.
|
||||
if tool.Ifc.get():
|
||||
get_cache_or_detect_lock()
|
||||
|
||||
|
||||
def _apply_user_preferences() -> None:
|
||||
"""User-preference-driven UI setup: toolbar, BIM workspace, viewport shading
|
||||
subscription, scene-panel hijack, tab layout, snap defaults."""
|
||||
preferences = tool.Blender.get_addon_preferences()
|
||||
if not preferences.should_setup_toolbar:
|
||||
tool.Blender.unregister_toolbar()
|
||||
@@ -491,21 +391,11 @@ def _apply_user_preferences() -> None:
|
||||
tool.Blender.override_scene_panel(panel)
|
||||
tool.Blender.setup_tabs()
|
||||
|
||||
if preferences.should_use_snap and (scene := bpy.context.scene):
|
||||
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
|
||||
scene.tool_settings.use_snap = True
|
||||
# Match default Bonsai snaps
|
||||
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
props.has_blend_warning = True
|
||||
|
||||
tool.Blender.sync_old_preferences()
|
||||
|
||||
|
||||
def _install_viewport_overlays() -> None:
|
||||
"""Sync every Bonsai viewport decorator to its enabled state.
|
||||
|
||||
Wrapped in uninstall/install of the decorator-cache bump handlers so a
|
||||
decorator's own install path doesn't double-bind to depsgraph_update_post
|
||||
via ``TokenCache`` instances created during their own ``install()``."""
|
||||
# Bonsai overlays
|
||||
georeference_props = tool.Georeference.get_georeference_props()
|
||||
aggregate_props = tool.Aggregate.get_aggregate_props()
|
||||
nest_props = tool.Nest.get_nest_props()
|
||||
@@ -515,62 +405,23 @@ 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:
|
||||
GeoreferenceDecorator.install(bpy.context)
|
||||
if aggregate_props.aggregate_decorator:
|
||||
AggregateDecorator.install(bpy.context)
|
||||
if nest_props.nest_decorator:
|
||||
NestDecorator.install(bpy.context)
|
||||
if model_props.show_wall_axis:
|
||||
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()
|
||||
if georeference_props.should_visualise:
|
||||
GeoreferenceDecorator.install(bpy.context)
|
||||
if aggregate_props.aggregate_decorator:
|
||||
AggregateDecorator.install(bpy.context)
|
||||
if nest_props.nest_decorator:
|
||||
NestDecorator.install(bpy.context)
|
||||
if model_props.show_wall_axis:
|
||||
WallAxisDecorator.install(bpy.context)
|
||||
if model_props.show_slab_direction:
|
||||
SlabDirectionDecorator.install(bpy.context)
|
||||
if model_props.show_bounding_box:
|
||||
BoundingBoxDecorator.install(bpy.context)
|
||||
|
||||
if preferences.should_use_snap and (scene := bpy.context.scene):
|
||||
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
|
||||
scene.tool_settings.use_snap = True
|
||||
# Match default Bonsai snaps
|
||||
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
|
||||
|
||||
@persistent
|
||||
def load_post(scene):
|
||||
_apply_save_file_invariants(scene)
|
||||
_apply_user_preferences()
|
||||
_install_viewport_overlays()
|
||||
tool.Blender.sync_old_preferences()
|
||||
|
||||
@@ -64,44 +64,6 @@ class TransactionStep(TypedDict):
|
||||
operations: list[Operation]
|
||||
|
||||
|
||||
# Set when ``IfcStore.get_cache`` observes an external lock on the HDF5 cache —
|
||||
# signal that another Blender process has the same IFC file open. Project panel
|
||||
# polls ``is_cache_locked_by_other_process`` to warn the user. The dismissed
|
||||
# flag is sticky per-session so the warning doesn't re-nag once the user has
|
||||
# acknowledged it.
|
||||
_cache_locked_by_other_process: bool = False
|
||||
_multi_instance_warning_dismissed: bool = False
|
||||
|
||||
|
||||
def is_cache_locked_by_other_process() -> bool:
|
||||
return _cache_locked_by_other_process and not _multi_instance_warning_dismissed
|
||||
|
||||
|
||||
def dismiss_multi_instance_warning() -> None:
|
||||
global _multi_instance_warning_dismissed
|
||||
_multi_instance_warning_dismissed = True
|
||||
|
||||
|
||||
def get_cache_or_detect_lock() -> ifcopenshell.geom.serializers.hdf5 | None:
|
||||
"""Like ``IfcStore.get_cache`` but tracks the multi-instance lock flag — sets
|
||||
it on ``PermissionError``, clears it (along with the dismiss flag) when a
|
||||
subsequent call succeeds. Returns ``None`` on lock; other exceptions
|
||||
propagate. Callers that don't need the warning side effect can use
|
||||
``IfcStore.get_cache`` directly."""
|
||||
global _cache_locked_by_other_process, _multi_instance_warning_dismissed
|
||||
try:
|
||||
cache = IfcStore.get_cache()
|
||||
except PermissionError:
|
||||
_cache_locked_by_other_process = True
|
||||
return None
|
||||
if _cache_locked_by_other_process:
|
||||
# Lock released — clear both flags so a future re-locking re-surfaces
|
||||
# the warning rather than staying suppressed by the previous dismiss.
|
||||
_cache_locked_by_other_process = False
|
||||
_multi_instance_warning_dismissed = False
|
||||
return cache
|
||||
|
||||
|
||||
class IfcStore:
|
||||
path: str = ""
|
||||
"""Should be set only using ``tool.Ifc.set_path``."""
|
||||
@@ -234,7 +196,7 @@ class IfcStore:
|
||||
shutil.copy2(IfcStore.cache_path, new_cache_path)
|
||||
except PermissionError:
|
||||
pass # Well we tried. No cache for you!
|
||||
get_cache_or_detect_lock()
|
||||
IfcStore.get_cache()
|
||||
|
||||
@staticmethod
|
||||
def load_file(path: str) -> None:
|
||||
@@ -552,7 +514,6 @@ class IfcStore:
|
||||
BrickStore.end_transaction()
|
||||
IfcStore.end_transaction(operator)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Parametric.refresh_post_commit(operator)
|
||||
|
||||
if method == "MODAL":
|
||||
cls.modal_in_progress = False
|
||||
@@ -566,19 +527,6 @@ 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,7 +223,6 @@ class IfcImporter:
|
||||
self.elements: set[ifcopenshell.entity_instance] = set()
|
||||
self.annotations: set[ifcopenshell.entity_instance] = set()
|
||||
self.gross_elements: set[ifcopenshell.entity_instance] = set()
|
||||
self.broken_arrays: set[ifcopenshell.entity_instance] = set()
|
||||
self.element_types: set[ifcopenshell.entity_instance] = set()
|
||||
self.spatial_elements: set[ifcopenshell.entity_instance] = set()
|
||||
self.meshes: dict[str, OBJECT_DATA_TYPE] = {}
|
||||
@@ -1220,18 +1219,8 @@ class IfcImporter:
|
||||
if element not in elements_to_import:
|
||||
continue
|
||||
for i in range(len(data)):
|
||||
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)
|
||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
|
||||
def update_linked_aggregates(self):
|
||||
# TODO Remove this after a while. See commit 17d6b8a
|
||||
|
||||
@@ -20,6 +20,7 @@ import blf
|
||||
import bpy
|
||||
import gpu
|
||||
import ifcopenshell.util.element
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras import view3d_utils
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
@@ -27,6 +28,12 @@ from mathutils import Vector
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
def create_bounding_box(objs):
|
||||
# Initialize the bounding box coordinates
|
||||
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
|
||||
@@ -72,8 +79,26 @@ def create_bounding_box(objs):
|
||||
return indices, edges
|
||||
|
||||
|
||||
class AggregateDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_aggregate"
|
||||
class AggregateDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_aggregate, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
@@ -128,6 +153,14 @@ class AggregateDecorator(tool.Blender.ViewportDecorator):
|
||||
shader.uniform_float("u_Scale", 25)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_aggregate(self, context):
|
||||
props = tool.Aggregate.get_aggregate_props()
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -158,13 +191,12 @@ class AggregateDecorator(tool.Blender.ViewportDecorator):
|
||||
aggregates.append(obj)
|
||||
continue
|
||||
|
||||
aggregate = None
|
||||
aggregates_list = tool.Aggregate.get_aggregates_recursively(element)
|
||||
if props.in_aggregate_mode and props.editing_aggregate:
|
||||
index = aggregates_list.index(tool.Ifc.get_entity(props.editing_aggregate))
|
||||
if index > 0:
|
||||
aggregate = aggregates_list[index - 1]
|
||||
elif aggregates_list:
|
||||
else:
|
||||
aggregate = aggregates_list[-1]
|
||||
if aggregate:
|
||||
aggregates.append(tool.Ifc.get_object(aggregate))
|
||||
@@ -193,11 +225,39 @@ class AggregateDecorator(tool.Blender.ViewportDecorator):
|
||||
self.draw_custom_batch(line, decorator_color_unselected)
|
||||
|
||||
|
||||
class AggregateModeDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_aggregate_name", "POST_PIXEL"),
|
||||
("draw_aggregate_empty", "POST_VIEW"),
|
||||
)
|
||||
class AggregateModeDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_aggregate_name, (context,), "WINDOW", "POST_PIXEL")
|
||||
)
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_aggregate_empty, (context,), "WINDOW", "POST_VIEW")
|
||||
)
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_aggregate_name(self, context):
|
||||
if context.mode == "EDIT_MESH":
|
||||
|
||||
@@ -139,7 +139,6 @@ class BIMAggregateProperties(PropertyGroup):
|
||||
previous_editing_aggregate: PointerProperty(name="Editing Aggregate", type=bpy.types.Object)
|
||||
editing_objects: CollectionProperty(type=Objects)
|
||||
not_editing_objects: CollectionProperty(type=Objects)
|
||||
previously_selected_objects: CollectionProperty(type=Objects)
|
||||
aggregate_decorator: BoolProperty(
|
||||
name="Display Aggregate",
|
||||
default=False,
|
||||
@@ -156,6 +155,5 @@ class BIMAggregateProperties(PropertyGroup):
|
||||
previous_editing_aggregate: Union[bpy.types.Object, None]
|
||||
editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
not_editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
previously_selected_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
aggregate_decorator: bool
|
||||
previous_state: bool
|
||||
|
||||
@@ -48,14 +48,12 @@ def draw_ui(context: bpy.types.Context, layout: bpy.types.UILayout, attributes)
|
||||
row = layout.row()
|
||||
op = row.operator("bim.enable_editing_attributes", icon="GREASEPENCIL", text="Edit")
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
key_prefix = "type." if (element and element.is_a("IfcTypeObject")) else ""
|
||||
for attribute in attributes:
|
||||
row = layout.row(align=True)
|
||||
row.label(text=attribute["name"])
|
||||
value = bonsai.bim.helper.get_display_value(attribute["value"])
|
||||
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
|
||||
op.key = key_prefix + attribute["name"]
|
||||
op.key = attribute["name"]
|
||||
|
||||
# TODO: reimplement, see #1222
|
||||
# if "IfcSite/" in context.active_object.name or "IfcBuilding/" in context.active_object.name:
|
||||
|
||||
@@ -56,6 +56,11 @@ class BoundaryDecorator:
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
special_elements_color = self.addon_prefs.decorator_color_special
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
gpu.state.point_size_set(6)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
@@ -104,11 +109,7 @@ class BoundaryDecorator:
|
||||
|
||||
if unselected_edges:
|
||||
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
|
||||
self.draw_batch(
|
||||
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
|
||||
)
|
||||
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
|
||||
if selected_edges:
|
||||
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
|
||||
self.draw_batch(
|
||||
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
|
||||
)
|
||||
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
|
||||
|
||||
@@ -345,17 +345,9 @@ class CadArcFrom3Points(bpy.types.Operator):
|
||||
class CadOffset(bpy.types.Operator):
|
||||
bl_idname = "bim.cad_offset"
|
||||
bl_label = "CAD Offset"
|
||||
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_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
|
||||
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):
|
||||
@@ -413,11 +405,6 @@ 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 = []
|
||||
@@ -530,15 +517,12 @@ 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)
|
||||
|
||||
@@ -547,14 +531,9 @@ class CadOffset(bpy.types.Operator):
|
||||
|
||||
v1 = v2
|
||||
|
||||
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.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]))
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
@@ -27,11 +27,6 @@ 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(
|
||||
@@ -42,7 +37,6 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: int
|
||||
radius: float
|
||||
distance: float
|
||||
copy: bool
|
||||
x: float
|
||||
y: float
|
||||
gable_roof_edge_angle: float
|
||||
|
||||
@@ -256,8 +256,6 @@ 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():
|
||||
@@ -293,7 +291,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, copy=self.props.copy)
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance)
|
||||
|
||||
def hotkey_S_Q(self):
|
||||
obj = bpy.context.active_object
|
||||
|
||||
@@ -18,17 +18,43 @@
|
||||
|
||||
import blf
|
||||
import gpu
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class ClashDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw_geometry", "POST_VIEW"),
|
||||
)
|
||||
class ClashDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_text(self, context):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# 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_pre(filepath):
|
||||
# Tear down any in-flight clip-box timers before Blender frees the
|
||||
# WM / screens / areas / regions for the loading file. A refresh timer
|
||||
# that survives the teardown fires against the new file's freshly-
|
||||
# allocated regions before their GPU state is wired, CTD-ing inside
|
||||
# GPU_matrix_ortho_set. The gate also blocks the depsgraph IFC-reload
|
||||
# branch and is held closed until on_pre_view fires for the first time
|
||||
# on the new file (first paint = GPU contexts wired).
|
||||
tool.ClipBox._file_loading = True
|
||||
tool.ClipBox._post_load_paint_pending = True
|
||||
tool.ClipBox._cancel_pending_refresh()
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
|
||||
@persistent
|
||||
def _on_load_post(filepath):
|
||||
# The _file_loading gate is NOT cleared here: load_post fires before
|
||||
# the new file's first paint, so GPU contexts may still be uninitialised.
|
||||
# on_pre_view consumes _post_load_paint_pending to open the gate at the
|
||||
# safe moment and kick the post-load re-arm.
|
||||
# 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_pre not in bpy.app.handlers.load_pre:
|
||||
bpy.app.handlers.load_pre.append(_on_load_pre)
|
||||
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_load_pre in bpy.app.handlers.load_pre:
|
||||
bpy.app.handlers.load_pre.remove(_on_load_pre)
|
||||
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_refresh()
|
||||
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
|
||||
@@ -1,212 +0,0 @@
|
||||
# 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")],
|
||||
)
|
||||
@@ -1,879 +0,0 @@
|
||||
# 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",
|
||||
]
|
||||
@@ -1,312 +0,0 @@
|
||||
# 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")
|
||||
@@ -1,294 +0,0 @@
|
||||
# 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"}
|
||||
@@ -1,165 +0,0 @@
|
||||
# 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
|
||||
@@ -1,145 +0,0 @@
|
||||
# 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")
|
||||
@@ -15,8 +15,6 @@
|
||||
#
|
||||
# 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 bpy
|
||||
|
||||
@@ -32,6 +30,7 @@ classes = (
|
||||
operator.ActivateModel,
|
||||
operator.AddAnnotation,
|
||||
operator.AddAnnotationType,
|
||||
operator.AssignManualDrawingReference,
|
||||
operator.AddDrawing,
|
||||
operator.AddDrawingStyle,
|
||||
operator.AddDrawingToSheet,
|
||||
@@ -109,6 +108,11 @@ classes = (
|
||||
operator.ToggleTargetView,
|
||||
operator.OpenDocumentationWebUi,
|
||||
operator.FilterSelectedObjectsIfIntersectedByCamera,
|
||||
operator.DrawParametricDimension,
|
||||
operator.SetDimensionAnchor,
|
||||
operator.RegenerateDimensions,
|
||||
operator.ClickNearestDimensionAnchor,
|
||||
operator.DebugDimensionClicks,
|
||||
prop.Variable,
|
||||
prop.Drawing,
|
||||
prop.Document,
|
||||
@@ -138,43 +142,29 @@ classes = (
|
||||
gizmos.GizmoArrow2D,
|
||||
gizmos.GizmoCone,
|
||||
gizmos.GizmoDimension,
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
gizmos.GizmoLock,
|
||||
gizmos.GizmoArc,
|
||||
gizmos.GizmoLinkToggle,
|
||||
gizmos.GizmoFillet,
|
||||
gizmos.GizmoWallCornerIcon,
|
||||
gizmos.GizmoWallTeeIcon,
|
||||
gizmos.GizmoPen,
|
||||
gizmos.GizmoValidate,
|
||||
gizmos.GizmoCancel,
|
||||
gizmos.GizmoPlus,
|
||||
gizmos.GizmoMinus,
|
||||
gizmos.GizmoTrash,
|
||||
gizmos.GizmoArrayParent,
|
||||
gizmos.GizmoArrayAll,
|
||||
gizmos.GizmoArrayLayerIndicator,
|
||||
gizmos.GizmoCountLabel,
|
||||
gizmos.GizmoMerge,
|
||||
gizmos.GizmoSplit,
|
||||
gizmos.GizmoUnjoin,
|
||||
gizmos.GizmoExtend,
|
||||
gizmos.GizmoExtendVertical,
|
||||
gizmos.GizmoOffsetExterior,
|
||||
gizmos.GizmoOffsetCenter,
|
||||
gizmos.GizmoOffsetInterior,
|
||||
gizmos.GizmoAddOpening,
|
||||
gizmos.GizmoCycle,
|
||||
gizmos.GizmoMenu,
|
||||
# Drawing-specific gizmos
|
||||
gizmos.UglyDotGizmo,
|
||||
gizmos.ExtrusionGuidesGizmo,
|
||||
gizmos.ExtrusionWidget,
|
||||
gizmos.GizmoAnchorHandle,
|
||||
gizmos.DimensionAnchorWidget,
|
||||
gizmos.DimensionLinePositionWidget,
|
||||
workspace.LaunchAnnotationTypeManager,
|
||||
workspace.Hotkey,
|
||||
)
|
||||
|
||||
|
||||
_keymaps = []
|
||||
|
||||
|
||||
def menu_func(self, context):
|
||||
active_obj = context.active_object
|
||||
if active_obj:
|
||||
@@ -194,9 +184,17 @@ def register():
|
||||
bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
|
||||
bpy.app.handlers.depsgraph_update_post.append(handler.depsgraph_update_post_handler)
|
||||
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
|
||||
|
||||
wm = bpy.context.window_manager
|
||||
kc = wm.keyconfigs.addon
|
||||
if kc:
|
||||
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
|
||||
kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS")
|
||||
_keymaps.append((km, kmi))
|
||||
|
||||
|
||||
def unregister():
|
||||
if not bpy.app.background:
|
||||
@@ -209,5 +207,10 @@ def unregister():
|
||||
del bpy.types.TextCurve.BIMTextProperties
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler)
|
||||
bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler)
|
||||
|
||||
for km, kmi in _keymaps:
|
||||
km.keymap_items.remove(kmi)
|
||||
_keymaps.clear()
|
||||
bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func)
|
||||
|
||||
@@ -55,6 +55,14 @@ class ProductAssignmentsData:
|
||||
element = tool.Ifc.get_entity(bpy.context.active_object)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
return
|
||||
# Document-reference annotations link to an IfcDocumentInformation, not a product.
|
||||
if tool.Drawing.is_document_reference(element):
|
||||
for rel in element.HasAssociations:
|
||||
if rel.is_a("IfcRelAssociatesDocument"):
|
||||
doc = rel.RelatingDocument
|
||||
if doc.is_a("IfcDocumentInformation"):
|
||||
return doc.Name or "Unnamed"
|
||||
return None
|
||||
for rel in element.HasAssignments:
|
||||
if rel.is_a("IfcRelAssignsToProduct"):
|
||||
name = rel.RelatingProduct.Name or "Unnamed"
|
||||
@@ -799,19 +807,24 @@ class DecoratorData:
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") or {}
|
||||
show_description_only = pset_data.get("ShowDescriptionOnly", False)
|
||||
suppress_zero_inches = pset_data.get("SuppressZeroInches", False)
|
||||
suppress_zero_feet = pset_data.get("SuppressZeroFeet", False)
|
||||
is_ordinate = pset_data.get("IsOrdinate", False)
|
||||
text_prefix = pset_data.get("TextPrefix", None) or ""
|
||||
text_suffix = pset_data.get("TextSuffix", None) or ""
|
||||
custom_unit_list = pset_data.get("CustomUnit", None) or ""
|
||||
custom_unit = custom_unit_list[0] if custom_unit_list else ""
|
||||
custom_units = list(pset_data.get("CustomUnit", None) or [])
|
||||
separator = pset_data.get("Separator", None) or " / "
|
||||
|
||||
return {
|
||||
"dimension_style": dimension_style,
|
||||
"show_description_only": show_description_only,
|
||||
"suppress_zero_inches": suppress_zero_inches,
|
||||
"suppress_zero_feet": suppress_zero_feet,
|
||||
"is_ordinate": is_ordinate,
|
||||
"text_prefix": text_prefix,
|
||||
"text_suffix": text_suffix,
|
||||
"fill_bg": fill_bg,
|
||||
"custom_unit": custom_unit,
|
||||
"custom_units": custom_units,
|
||||
"separator": separator,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -490,7 +490,7 @@ class BaseDecorator:
|
||||
self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs)
|
||||
|
||||
@cache
|
||||
def format_value(self, context, value, suppress_zero_inches=False, custom_unit=None, in_unit_length=False):
|
||||
def format_value(self, context, value, suppress_zero_inches=False, suppress_zero_feet=False, custom_unit=None, in_unit_length=False):
|
||||
drawing_pset_data = DrawingsData.data["active_drawing_pset_data"]
|
||||
precision = drawing_pset_data.get("MetricPrecision", None)
|
||||
if not precision:
|
||||
@@ -502,6 +502,7 @@ class BaseDecorator:
|
||||
precision=precision,
|
||||
decimal_places=decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
suppress_zero_feet=suppress_zero_feet,
|
||||
custom_unit=custom_unit,
|
||||
in_unit_length=in_unit_length,
|
||||
)
|
||||
@@ -718,11 +719,13 @@ class DimensionDecorator(BaseDecorator):
|
||||
if not dimension_data:
|
||||
return
|
||||
show_description_only = dimension_data["show_description_only"]
|
||||
is_ordinate = dimension_data["is_ordinate"]
|
||||
text_prefix = dimension_data["text_prefix"]
|
||||
text_suffix = dimension_data["text_suffix"]
|
||||
viewportDrawingScale = self.get_viewport_drawing_scale(context)
|
||||
text_offset_value = viewportDrawingScale * 3
|
||||
|
||||
ordinate_total = 0.0
|
||||
for i0, i1 in indices:
|
||||
v0 = Vector(vertices[i0])
|
||||
v1 = Vector(vertices[i1])
|
||||
@@ -741,16 +744,25 @@ class DimensionDecorator(BaseDecorator):
|
||||
"multiline": True,
|
||||
"text_dir": text_dir,
|
||||
}
|
||||
base_pos = p0 + text_dir * 0.5
|
||||
base_pos = p1 if is_ordinate else p0 + text_dir * 0.5
|
||||
|
||||
if not show_description_only:
|
||||
length = (v1 - v0).length
|
||||
text = self.format_value(
|
||||
context,
|
||||
length,
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
segment_length = (v1 - v0).length
|
||||
if is_ordinate:
|
||||
ordinate_total += segment_length
|
||||
length = ordinate_total if is_ordinate else segment_length
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [
|
||||
self.format_value(
|
||||
context,
|
||||
length,
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
text = dimension_data["separator"].join(str(p) for p in parts)
|
||||
if isinstance(self, DiameterDecorator):
|
||||
text = "D" + text
|
||||
text = text_prefix + text + text_suffix
|
||||
@@ -761,15 +773,18 @@ class DimensionDecorator(BaseDecorator):
|
||||
|
||||
self.draw_label(
|
||||
text=text,
|
||||
pos=base_pos + text_offset,
|
||||
box_alignment="bottom-middle",
|
||||
pos=base_pos + text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
|
||||
box_alignment="bottom-right" if is_ordinate else "bottom-middle",
|
||||
multiline_to_bottom=False,
|
||||
**common_label_attrs,
|
||||
)
|
||||
|
||||
if not show_description_only and description:
|
||||
self.draw_label(
|
||||
text=description, pos=base_pos - text_offset, box_alignment="top-middle", **common_label_attrs
|
||||
text=description,
|
||||
pos=base_pos - text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
|
||||
box_alignment="top-right" if is_ordinate else "top-middle",
|
||||
**common_label_attrs,
|
||||
)
|
||||
|
||||
|
||||
@@ -965,7 +980,9 @@ class RadiusDecorator(BaseDecorator):
|
||||
|
||||
def get_text():
|
||||
length = (spline_points[-1] - spline_points[-2]).length
|
||||
return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"])
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [self.format_value(context, length, suppress_zero_feet=dimension_data["suppress_zero_feet"], custom_unit=unit) for unit in units_to_format]
|
||||
return "R" + dimension_data["separator"].join(str(p) for p in parts)
|
||||
|
||||
self.draw_dimension_text(
|
||||
context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center"
|
||||
@@ -1497,6 +1514,20 @@ class ElevationDecorator(BaseDecorator):
|
||||
"output_edges": output_edges,
|
||||
}
|
||||
|
||||
# Determine the arrow direction in camera-image-plane (XY) space.
|
||||
# The elevation tag's local -Z is intentionally parallel to the drawing
|
||||
# camera's view direction, so projecting it always gives a near-zero XY
|
||||
# delta. Fall through to local +X (which is perpendicular to the view
|
||||
# and rotates visibly when the user spins the tag).
|
||||
view_mat = context.region_data.view_matrix
|
||||
edge_dir_2d = Vector((1.0, 0.0)) # final fallback
|
||||
for local_axis in (Vector((0, 0, -1)), Vector((1, 0, 0)), Vector((0, 1, 0))):
|
||||
world_axis = obj.matrix_world.to_3x3() @ local_axis
|
||||
cam_xy = (view_mat.to_3x3() @ world_axis).xy
|
||||
if cam_xy.length > 1e-6:
|
||||
edge_dir_2d = cam_xy.normalized()
|
||||
break
|
||||
|
||||
# process edges
|
||||
for edge in edges_original:
|
||||
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
|
||||
@@ -1505,7 +1536,7 @@ class ElevationDecorator(BaseDecorator):
|
||||
circle_head = get_circle_head(circle_size)
|
||||
start_i = add_verts_sequence(add_offsets(v0, circle_head), start_i, **out_kwargs, closed=True)
|
||||
|
||||
edge_dir = (v1 - v0).normalized()
|
||||
edge_dir = edge_dir_2d.to_3d()
|
||||
side = (edge_dir.yx * Vector((1, -1))).to_3d()
|
||||
triangle_head = get_triangle_head(side, edge_dir, triangle_length, triangle_width)
|
||||
start_i = add_verts_sequence(add_offsets(v0, triangle_head), start_i, **out_kwargs, closed=True)
|
||||
@@ -2090,4 +2121,8 @@ class DecorationsHandler:
|
||||
|
||||
object_decorators = DecoratorData.data.get("object_decorators", [])
|
||||
for obj, decorator in object_decorators:
|
||||
decorator.decorate(context, obj)
|
||||
try:
|
||||
decorator.decorate(context, obj)
|
||||
except ReferenceError:
|
||||
DecoratorData.is_loaded = False
|
||||
break
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,15 +16,141 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
import bonsai.bim.module.drawing.decoration as decoration
|
||||
import bonsai.tool as tool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parametric dimension auto-regeneration state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Maps element GUID → list of annotation STEP IDs that reference it.
|
||||
_dim_guid_index: dict = {}
|
||||
# Persistent tessellation cache for the depsgraph handler (element id → shape).
|
||||
_dim_shape_cache: dict = {}
|
||||
# Set True whenever BBIM_Dimension anchors change or a new file loads.
|
||||
_dim_index_dirty: bool = True
|
||||
# Re-entry guard so curve updates don't trigger a second handler call.
|
||||
_dim_handler_running: bool = False
|
||||
|
||||
|
||||
def invalidate_dim_index() -> None:
|
||||
"""Mark the GUID index as stale so it is rebuilt on the next handler call."""
|
||||
global _dim_index_dirty, _dim_shape_cache
|
||||
_dim_index_dirty = True
|
||||
_dim_shape_cache.clear()
|
||||
|
||||
|
||||
def _rebuild_dim_guid_index(file) -> None:
|
||||
global _dim_guid_index, _dim_index_dirty
|
||||
import ifcopenshell.util.element
|
||||
|
||||
_dim_guid_index = {}
|
||||
for annotation in file.by_type("IfcAnnotation"):
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
continue
|
||||
try:
|
||||
anchors = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
continue
|
||||
ann_id = annotation.id()
|
||||
for anchor in anchors:
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
ids = _dim_guid_index.setdefault(guid, [])
|
||||
if ann_id not in ids:
|
||||
ids.append(ann_id)
|
||||
_dim_index_dirty = False
|
||||
|
||||
|
||||
def regenerate_dims_for_layer(file, layer) -> None:
|
||||
"""Regenerate all parametric dimensions anchored to elements that use *layer*."""
|
||||
global _dim_shape_cache, _dim_index_dirty, _dim_guid_index
|
||||
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
affected_guids: set = set()
|
||||
for layer_set in file.get_inverse(layer):
|
||||
if not layer_set.is_a("IfcMaterialLayerSet"):
|
||||
continue
|
||||
for inv in file.get_inverse(layer_set):
|
||||
if inv.is_a("IfcRelAssociatesMaterial"):
|
||||
rels = [inv]
|
||||
elif inv.is_a("IfcMaterialLayerSetUsage"):
|
||||
rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")]
|
||||
else:
|
||||
continue
|
||||
for rel in rels:
|
||||
for element in rel.RelatedObjects:
|
||||
if hasattr(element, "GlobalId"):
|
||||
affected_guids.add(element.GlobalId)
|
||||
_dim_shape_cache.pop(element.id(), None)
|
||||
|
||||
if not affected_guids:
|
||||
return
|
||||
|
||||
annotation_ids: set = set()
|
||||
for guid in affected_guids:
|
||||
for ann_id in _dim_guid_index.get(guid, []):
|
||||
annotation_ids.add(ann_id)
|
||||
|
||||
if not annotation_ids:
|
||||
return
|
||||
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import ifcopenshell.geom
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
|
||||
|
||||
for ann_id in annotation_ids:
|
||||
try:
|
||||
annotation = file.by_id(ann_id)
|
||||
except Exception:
|
||||
continue
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
placement_override: dict = {}
|
||||
try:
|
||||
anchors_raw = json.loads(pset.get("Anchors") or "[]")
|
||||
for anchor in anchors_raw:
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
settings=geom_settings,
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
|
||||
|
||||
@persistent
|
||||
def load_post(*args):
|
||||
invalidate_dim_index()
|
||||
props = tool.Drawing.get_document_props()
|
||||
if props.should_draw_decorations:
|
||||
decoration.DecorationsHandler.install(bpy.context)
|
||||
@@ -50,9 +176,6 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
|
||||
if camera.type != props.camera_type:
|
||||
camera.type = props.camera_type
|
||||
|
||||
if props.update_props and (drawing := tool.Ifc.get_entity(camera_obj)):
|
||||
tool.Drawing.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
ortho_scale, aspect_ratio = props.get_scale_and_aspect_ratio()
|
||||
scene_render = scene.render
|
||||
if (camera.ortho_scale != ortho_scale) or not tool.Cad.is_x(
|
||||
@@ -61,3 +184,177 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
|
||||
raster_x, raster_y = props.update_camera_resolution()
|
||||
scene_render.resolution_x = raster_x
|
||||
scene_render.resolution_y = raster_y
|
||||
|
||||
|
||||
def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
|
||||
"""Sync BBIM_Dimension.Anchors length to match the curve's spline point count.
|
||||
|
||||
Called when the user adds or removes vertices from a dimension annotation in
|
||||
Edit Mode. New vertices get a free WORLD-type anchor at their current world
|
||||
position; removed tail vertices simply lose their anchor entries.
|
||||
|
||||
Returns True if the pset was changed.
|
||||
"""
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
|
||||
if not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
|
||||
return False
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
return False
|
||||
|
||||
try:
|
||||
anchors: list = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
spline = obj.data.splines[0]
|
||||
spline_world = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
|
||||
n_pts = len(spline_world)
|
||||
n_anchors = len(anchors)
|
||||
|
||||
if n_pts == n_anchors:
|
||||
return False
|
||||
|
||||
# Match each spline point to the nearest unused anchor by proximity.
|
||||
# This handles insertions (subdivide) and deletions correctly regardless
|
||||
# of where in the polyline the edit happened.
|
||||
_MATCH_THRESH_SQ = 1e-4 # 1 cm² — distinguishes existing pts from new midpoints
|
||||
used: set = set()
|
||||
new_anchors: list = []
|
||||
|
||||
for pt in spline_world:
|
||||
best_idx, best_sq = None, float("inf")
|
||||
for i, anc in enumerate(anchors):
|
||||
if i in used:
|
||||
continue
|
||||
stored = anc.get("pt")
|
||||
if not stored:
|
||||
continue
|
||||
dx, dy, dz = stored[0] - pt.x, stored[1] - pt.y, stored[2] - pt.z
|
||||
sq = dx * dx + dy * dy + dz * dz
|
||||
if sq < best_sq:
|
||||
best_sq, best_idx = sq, i
|
||||
if best_idx is not None and best_sq < _MATCH_THRESH_SQ:
|
||||
new_anchors.append(anchors[best_idx])
|
||||
used.add(best_idx)
|
||||
else:
|
||||
new_anchors.append({
|
||||
"guid": None,
|
||||
"type": "WORLD",
|
||||
"addr": {},
|
||||
"hint": None,
|
||||
"pt": [pt.x, pt.y, pt.z],
|
||||
})
|
||||
|
||||
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
|
||||
invalidate_dim_index()
|
||||
return True
|
||||
|
||||
|
||||
@persistent
|
||||
def depsgraph_update_post_handler(scene, depsgraph):
|
||||
"""Auto-regenerate parametric dimensions when referenced elements are moved."""
|
||||
global _dim_handler_running, _dim_index_dirty, _dim_guid_index, _dim_shape_cache
|
||||
|
||||
if _dim_handler_running:
|
||||
return
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
moved_guids: set = set()
|
||||
edited_annotation_ids: set = set()
|
||||
|
||||
for update in depsgraph.updates:
|
||||
obj = update.id
|
||||
if not isinstance(obj, bpy.types.Object):
|
||||
continue
|
||||
if not (update.is_updated_transform or update.is_updated_geometry):
|
||||
continue
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not hasattr(element, "GlobalId"):
|
||||
continue
|
||||
|
||||
|
||||
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
|
||||
import ifcopenshell.util.element as _ue
|
||||
ptype = _ue.get_predefined_type(element)
|
||||
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
|
||||
changed = _sync_dimension_anchors_to_curve(file, element, obj)
|
||||
if changed:
|
||||
edited_annotation_ids.add(element.id())
|
||||
continue
|
||||
|
||||
moved_guids.add(element.GlobalId)
|
||||
if update.is_updated_geometry:
|
||||
_dim_shape_cache.pop(element.id(), None)
|
||||
|
||||
annotation_ids: set = set(edited_annotation_ids)
|
||||
for guid in moved_guids:
|
||||
for ann_id in _dim_guid_index.get(guid, []):
|
||||
annotation_ids.add(ann_id)
|
||||
|
||||
if not annotation_ids:
|
||||
return
|
||||
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import ifcopenshell.geom
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
|
||||
|
||||
_dim_handler_running = True
|
||||
try:
|
||||
for ann_id in annotation_ids:
|
||||
try:
|
||||
annotation = file.by_id(ann_id)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
|
||||
placement_override: dict = {}
|
||||
try:
|
||||
anchors_raw = json.loads(pset.get("Anchors") or "[]")
|
||||
for anchor in anchors_raw:
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_id = elem.id()
|
||||
if elem_id in placement_override:
|
||||
continue
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem_id] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
settings=geom_settings,
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
finally:
|
||||
_dim_handler_running = False
|
||||
|
||||
@@ -170,6 +170,7 @@ def format_distance(
|
||||
precision=None,
|
||||
decimal_places=None,
|
||||
suppress_zero_inches=False,
|
||||
suppress_zero_feet=False,
|
||||
in_unit_length=False,
|
||||
custom_unit=None,
|
||||
):
|
||||
@@ -310,10 +311,10 @@ def format_distance(
|
||||
tx_dist = ""
|
||||
if feet:
|
||||
tx_dist += str(feet) + "'"
|
||||
if not feet and not add_inches:
|
||||
if not feet and not add_inches and not suppress_zero_feet:
|
||||
tx_dist += str(feet) + "'"
|
||||
|
||||
if not feet and add_inches and unit_length != "INCHES":
|
||||
if not feet and add_inches and unit_length != "INCHES" and not suppress_zero_feet:
|
||||
if value < 0:
|
||||
tx_dist += "-0' - "
|
||||
else:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -604,7 +604,6 @@ class BIMCameraProperties(PropertyGroup):
|
||||
return tool.Blender.get_active_uilist_element(dprops.drawing_styles, self.active_drawing_style_index)
|
||||
|
||||
# For now, this JSON dump are all the parameters that determine a camera's "Block representation"
|
||||
# Perspective camera shift is stored in EPset_Drawing and intentionally excluded here.
|
||||
# By checking this, you will know whether or not the camera IFC representation needs to be refreshed
|
||||
def update_representation(self, matrix_world: Matrix) -> bool:
|
||||
"""Update ``representation`` based on current camera properties and the provided world matrix.
|
||||
@@ -987,6 +986,160 @@ def update_sheet_data(self, context):
|
||||
SheetsData.is_loaded = False
|
||||
|
||||
|
||||
def _update_force_perpendicular(self, context):
|
||||
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as tool
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
new_value = self.force_perpendicular_to_face
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
|
||||
|
||||
targets = []
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||
continue
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
continue
|
||||
targets.append((obj, element, pset_data))
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
for obj, element, pset_data in targets:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"ForcePerpendicularToFace": new_value})
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
|
||||
|
||||
def _get_line_position(self) -> float:
|
||||
"""Return LinePosition from the active annotation's BBIM_Dimension pset.
|
||||
|
||||
Falls back to the natural anchor projection when LinePosition has not been
|
||||
explicitly set, so the field always shows a meaningful value.
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
try:
|
||||
import bpy as _bpy
|
||||
import ifcopenshell.util.element as _ue
|
||||
import bonsai.tool as _tool
|
||||
obj = getattr(_bpy.context, "active_object", None)
|
||||
if obj:
|
||||
element = _tool.Ifc.get_entity(obj)
|
||||
if element and element.is_a("IfcAnnotation"):
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if pset:
|
||||
stored = pset.get("LinePosition")
|
||||
if stored is not None:
|
||||
return float(stored)
|
||||
raw = pset.get("Anchors")
|
||||
if raw:
|
||||
anchors = json.loads(raw)
|
||||
if len(anchors) >= 2 and anchors[0].get("pt") and anchors[1].get("pt"):
|
||||
a, b = anchors[0]["pt"], anchors[1]["pt"]
|
||||
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
|
||||
m = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if m > 1e-10:
|
||||
ddx, ddy, ddz = dx / m, dy / m, dz / m
|
||||
# cross(world_Z=(0,0,1), dim_dir) = (-ddy, ddx, 0)
|
||||
ox, oy, oz = -ddy, ddx, 0.0
|
||||
om = math.sqrt(ox * ox + oy * oy)
|
||||
if om > 1e-6:
|
||||
od = (ox / om, oy / om, 0.0)
|
||||
pt = anchors[0]["pt"]
|
||||
return float(pt[0] * od[0] + pt[1] * od[1])
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def _set_line_position(self, value: float) -> None:
|
||||
"""Write LinePosition to all selected dimension annotations and regenerate."""
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as tool
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
|
||||
|
||||
targets = []
|
||||
import bpy as _bpy
|
||||
for obj in getattr(_bpy.context, "selected_objects", []):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||
continue
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
continue
|
||||
targets.append((obj, element, pset_data))
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
for obj, element, pset_data in targets:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"LinePosition": value})
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
|
||||
|
||||
class BIMAnnotationProperties(PropertyGroup):
|
||||
object_type: bpy.props.EnumProperty(
|
||||
name="Annotation Object Type", items=annotation_classes, default="TEXT", update=update_annotation_object_type
|
||||
@@ -1000,6 +1153,25 @@ class BIMAnnotationProperties(PropertyGroup):
|
||||
)
|
||||
is_adding_type: bpy.props.BoolProperty(default=False)
|
||||
type_name: bpy.props.StringProperty(name="Name", default="TYPEX")
|
||||
force_perpendicular_to_face: bpy.props.BoolProperty(
|
||||
name="Force ⊥ to Face",
|
||||
description="Constrain dimension vertices to the face normal of the first anchor. When dimensions are selected, toggling this updates them all.",
|
||||
default=False,
|
||||
update=_update_force_perpendicular,
|
||||
)
|
||||
line_position: bpy.props.FloatProperty(
|
||||
name="Line Position",
|
||||
description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.",
|
||||
unit="LENGTH",
|
||||
get=_get_line_position,
|
||||
set=_set_line_position,
|
||||
)
|
||||
is_manual_reference: bpy.props.BoolProperty(
|
||||
name="Is a Reference",
|
||||
default=False,
|
||||
description="Place as a manual reference tag (IsManualDrawingReference). "
|
||||
"Exempt from automatic drawing regeneration. Optionally link to a drawing or external reference.",
|
||||
)
|
||||
tag_rotation_mode: bpy.props.EnumProperty(
|
||||
name="Tag Rotation Mode",
|
||||
description="How to orient the tag relative to the tagged object",
|
||||
@@ -1020,3 +1192,4 @@ class BIMAnnotationProperties(PropertyGroup):
|
||||
create_representation_for_type: bool
|
||||
is_adding_type: bool
|
||||
type_name: str
|
||||
is_manual_reference: bool
|
||||
|
||||
@@ -872,7 +872,11 @@ class SvgWriter:
|
||||
|
||||
v1 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, 0)))
|
||||
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, -1)))
|
||||
angle = -math.degrees((v2 - v1).xy.angle_signed(Vector((0, 1))))
|
||||
delta = (v2 - v1).xy
|
||||
if delta.length <= 1e-6:
|
||||
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((1, 0, 0)))
|
||||
delta = (v2 - v1).xy
|
||||
angle = -math.degrees(delta.angle_signed(Vector((0, 1)))) if delta.length > 1e-6 else 90.0
|
||||
|
||||
transform = "rotate({}, {}, {})".format(angle, *symbol_position_svg.xy)
|
||||
|
||||
@@ -892,9 +896,35 @@ class SvgWriter:
|
||||
)
|
||||
|
||||
def get_reference_and_sheet_id_from_annotation(self, element: ifcopenshell.entity_instance) -> tuple[str, str]:
|
||||
reference_id = "-"
|
||||
sheet_id = "-"
|
||||
is_ifc2x3 = tool.Ifc.get_schema() == "IFC2X3"
|
||||
|
||||
# Document-reference annotations link to an IfcDocumentInformation via
|
||||
# IfcRelAssociatesDocument rather than to a drawing product.
|
||||
if tool.Drawing.is_document_reference(element):
|
||||
doc_info = tool.Drawing.get_annotation_reference_doc(element)
|
||||
if not doc_info:
|
||||
return ("-", "-")
|
||||
ext_location = tool.Drawing.get_path_with_ext(
|
||||
(doc_info.DocumentReferences[0].Location if is_ifc2x3 else doc_info.HasDocumentReferences[0].Location),
|
||||
"svg",
|
||||
) if (doc_info.DocumentReferences if is_ifc2x3 else doc_info.HasDocumentReferences) else None
|
||||
if not ext_location:
|
||||
return ("-", "-")
|
||||
for sheet_reference in tool.Ifc.get().by_type("IfcDocumentReference"):
|
||||
if tool.Drawing.get_reference_description(sheet_reference) != "REFERENCE":
|
||||
continue
|
||||
if sheet_reference.Location != ext_location:
|
||||
continue
|
||||
sheet = tool.Drawing.get_reference_document(sheet_reference)
|
||||
if sheet:
|
||||
if is_ifc2x3:
|
||||
return (sheet_reference.ItemReference or "-", sheet.DocumentId or "-")
|
||||
return (sheet_reference.Identification or "-", sheet.Identification or "-")
|
||||
return ("-", "-")
|
||||
|
||||
drawing = tool.Drawing.get_annotation_element(element)
|
||||
if not drawing:
|
||||
return ("-", "-")
|
||||
reference = tool.Drawing.get_drawing_reference(drawing)
|
||||
if reference:
|
||||
for sheet_reference in tool.Ifc.get().by_type("IfcDocumentReference"):
|
||||
@@ -903,13 +933,9 @@ class SvgWriter:
|
||||
continue
|
||||
sheet = tool.Drawing.get_reference_document(sheet_reference)
|
||||
if sheet:
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
reference_id = sheet_reference.ItemReference or "-"
|
||||
sheet_id = sheet.DocumentId or "-"
|
||||
else:
|
||||
reference_id = sheet_reference.Identification or "-"
|
||||
sheet_id = sheet.Identification or "-"
|
||||
return (reference_id, sheet_id)
|
||||
if is_ifc2x3:
|
||||
return (sheet_reference.ItemReference or "-", sheet.DocumentId or "-")
|
||||
return (sheet_reference.Identification or "-", sheet.Identification or "-")
|
||||
break
|
||||
return ("-", "-")
|
||||
|
||||
@@ -1371,14 +1397,18 @@ class SvgWriter:
|
||||
|
||||
def get_text():
|
||||
radius = (points[-1].co - points[-2].co).length
|
||||
radius = helper.format_distance(
|
||||
radius,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
text = f"R{radius}"
|
||||
return text
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [
|
||||
helper.format_distance(
|
||||
radius,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
return "R" + dimension_data["separator"].join(str(p) for p in parts)
|
||||
|
||||
self.draw_dimension_text(
|
||||
get_text, tag, dimension_data, text_position=text_position, class_str="RADIUS", box_alignment="center"
|
||||
@@ -1503,10 +1533,12 @@ class SvgWriter:
|
||||
text_format=lambda x: "D" + x,
|
||||
show_description_only=dimension_data["show_description_only"],
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
text_prefix=dimension_data["text_prefix"],
|
||||
text_suffix=dimension_data["text_suffix"],
|
||||
fill_bg=dimension_data["fill_bg"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
custom_units=dimension_data["custom_units"],
|
||||
separator=dimension_data["separator"],
|
||||
)
|
||||
|
||||
def draw_dimension_annotations(self, obj: bpy.types.Object) -> None:
|
||||
@@ -1517,11 +1549,15 @@ class SvgWriter:
|
||||
dimension_data = DecoratorData.get_dimension_data(obj)
|
||||
|
||||
assert isinstance(obj.data, bpy.types.Curve)
|
||||
is_ordinate = dimension_data["is_ordinate"]
|
||||
for spline in obj.data.splines:
|
||||
points = self.get_spline_points(spline)
|
||||
ordinate_total = 0.0
|
||||
for i in range(len(points) - 1):
|
||||
v0_global = matrix_world @ points[i].co.xyz
|
||||
v1_global = matrix_world @ points[i + 1].co.xyz
|
||||
if is_ordinate:
|
||||
ordinate_total += (v1_global - v0_global).length
|
||||
self.draw_dimension_annotation(
|
||||
v0_global,
|
||||
v1_global,
|
||||
@@ -1529,10 +1565,13 @@ class SvgWriter:
|
||||
dimension_text=dimension_text,
|
||||
show_description_only=dimension_data["show_description_only"],
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
text_prefix=dimension_data["text_prefix"],
|
||||
text_suffix=dimension_data["text_suffix"],
|
||||
fill_bg=dimension_data["fill_bg"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
custom_units=dimension_data["custom_units"],
|
||||
separator=dimension_data["separator"],
|
||||
distance_override=ordinate_total if is_ordinate else None,
|
||||
)
|
||||
|
||||
def draw_measureit_arch_dimension_annotations(self) -> None:
|
||||
@@ -1556,10 +1595,13 @@ class SvgWriter:
|
||||
text_format=lambda x: x,
|
||||
show_description_only=False,
|
||||
suppress_zero_inches=False,
|
||||
suppress_zero_feet=False,
|
||||
text_prefix="",
|
||||
text_suffix="",
|
||||
fill_bg=False,
|
||||
custom_unit=None,
|
||||
custom_units=None,
|
||||
separator=" / ",
|
||||
distance_override=None,
|
||||
) -> None:
|
||||
offset = Vector([self.raw_width, self.raw_height]) / 2
|
||||
v0 = self.project_point_onto_camera(v0_global)
|
||||
@@ -1572,7 +1614,10 @@ class SvgWriter:
|
||||
sheet_dimension = (end - start).length
|
||||
|
||||
# if annotation can't fit offset text to the right of marker
|
||||
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
|
||||
if distance_override is not None:
|
||||
text_position = end
|
||||
else:
|
||||
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
|
||||
angle = math.degrees(vector.angle_signed(Vector((1, 0))))
|
||||
|
||||
line = self.svg.line(start=start, end=end, class_=" ".join(classes))
|
||||
@@ -1587,15 +1632,20 @@ class SvgWriter:
|
||||
}
|
||||
|
||||
if not show_description_only:
|
||||
dimension = (v1_global - v0_global).length
|
||||
dimension = helper.format_distance(
|
||||
dimension,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
custom_unit=custom_unit,
|
||||
)
|
||||
text = text_prefix + str(dimension) + text_suffix
|
||||
dimension = distance_override if distance_override is not None else (v1_global - v0_global).length
|
||||
units_to_format = custom_units if custom_units else [None]
|
||||
parts = [
|
||||
helper.format_distance(
|
||||
dimension,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
suppress_zero_feet=suppress_zero_feet,
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
text = text_prefix + separator.join(str(p) for p in parts) + text_suffix
|
||||
else:
|
||||
if not dimension_text:
|
||||
return
|
||||
@@ -1603,8 +1653,8 @@ class SvgWriter:
|
||||
|
||||
text_tags += self.create_text_tag(
|
||||
text,
|
||||
text_position + perpendicular,
|
||||
box_alignment="bottom-middle",
|
||||
text_position + perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
|
||||
box_alignment="bottom-right" if distance_override is not None else "bottom-middle",
|
||||
multiline_to_bottom=False,
|
||||
**text_tag_kwargs,
|
||||
)
|
||||
@@ -1612,8 +1662,8 @@ class SvgWriter:
|
||||
if not show_description_only and dimension_text:
|
||||
text_tags += self.create_text_tag(
|
||||
dimension_text,
|
||||
text_position - perpendicular,
|
||||
box_alignment="top-middle",
|
||||
text_position - perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
|
||||
box_alignment="top-right" if distance_override is not None else "top-middle",
|
||||
multiline_to_bottom=True,
|
||||
**text_tag_kwargs,
|
||||
)
|
||||
|
||||
@@ -99,10 +99,6 @@ class BIM_PT_camera(Panel):
|
||||
if props.target_view == "MODEL_VIEW":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "camera_type")
|
||||
if props.camera_type == "PERSP":
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(camera_data, "shift_x", text="Camera Shift X/Y:")
|
||||
row.prop(camera_data, "shift_y", text="")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "linework_mode")
|
||||
@@ -539,6 +535,17 @@ class BIM_PT_product_assignments(Panel):
|
||||
|
||||
assert self.layout
|
||||
assert (obj := context.active_object)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element and tool.Drawing.is_manual_drawing_reference(element):
|
||||
row = self.layout.row(align=True)
|
||||
fallback = "No Reference Assigned" if element.ObjectType == "REFERENCE" else "No Drawing Assigned"
|
||||
row.label(
|
||||
text=ProductAssignmentsData.data["relating_product"] or fallback, icon="IMAGE_DATA"
|
||||
)
|
||||
row.operator("bim.assign_manual_drawing_reference", icon="GREASEPENCIL", text="")
|
||||
return
|
||||
|
||||
props = tool.Drawing.get_object_assigned_product_props(obj)
|
||||
|
||||
if props.is_editing_product:
|
||||
@@ -556,6 +563,7 @@ class BIM_PT_product_assignments(Panel):
|
||||
col.enabled = bool(ProductAssignmentsData.data["relating_product"])
|
||||
|
||||
|
||||
|
||||
def get_category_icon(category_name):
|
||||
"""Get appropriate icon for each category"""
|
||||
icons = {
|
||||
|
||||
@@ -114,7 +114,11 @@ class AnnotationTool(WorkSpaceTool):
|
||||
bl_description = "Gives you Annotation related superpowers"
|
||||
bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.annotation")
|
||||
bl_widget = None
|
||||
bl_keymap = tool.Blender.get_default_selection_keypmap() + (
|
||||
bl_keymap = (
|
||||
# Before view3d.select: tool keymaps take priority over the addon keymap
|
||||
# where ClickNearestDimensionAnchor is also registered.
|
||||
("bim.click_nearest_dimension_anchor", {"type": "LEFTMOUSE", "value": "PRESS"}, None),
|
||||
) + tool.Blender.get_default_selection_keypmap() + (
|
||||
("bim.annotation_hotkey", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}),
|
||||
("bim.annotation_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}),
|
||||
("bim.annotation_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}),
|
||||
@@ -221,11 +225,29 @@ class AnnotationToolUI:
|
||||
props = tool.Drawing.get_document_props()
|
||||
row.prop(props, "should_draw_decorations", text="Viewport Annotations")
|
||||
|
||||
_DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
|
||||
|
||||
@classmethod
|
||||
def draw_edit_object_interface(cls, context):
|
||||
if DecoratorData.get_text_data(bpy.context.active_object):
|
||||
obj = bpy.context.active_object
|
||||
if tool.Ifc.get_entity(obj) and DecoratorData.get_text_data(obj):
|
||||
add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "")
|
||||
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj) if obj else None
|
||||
if element and element.is_a("IfcAnnotation"):
|
||||
ptype = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if ptype in cls._DIMENSION_TYPES:
|
||||
cls.layout.separator()
|
||||
ann_props = tool.Drawing.get_annotation_props()
|
||||
if ann_props.force_perpendicular_to_face:
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(ann_props, "line_position")
|
||||
cls.layout.separator()
|
||||
row = cls.layout.row(align=True)
|
||||
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
|
||||
op.active_only = True
|
||||
|
||||
@classmethod
|
||||
def draw_type_selection_interface(cls):
|
||||
# shared by both sidebar and header
|
||||
@@ -248,6 +270,15 @@ class AnnotationToolUI:
|
||||
|
||||
add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation")
|
||||
|
||||
if object_type in ("ELEVATION", "SECTION"):
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(cls.props, "is_manual_reference")
|
||||
|
||||
_DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"}
|
||||
if object_type in _DIMENSION_TYPES:
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(cls.props, "force_perpendicular_to_face")
|
||||
|
||||
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
|
||||
row = cls.layout.row(align=True)
|
||||
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
|
||||
@@ -330,9 +361,17 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if created_objects:
|
||||
bpy.context.view_layer.objects.active = created_objects[-1]
|
||||
|
||||
_PARAMETRIC_DIMENSION_TYPES = frozenset(
|
||||
("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")
|
||||
)
|
||||
|
||||
def hotkey_S_A(self):
|
||||
if bpy.ops.bim.add_annotation.poll():
|
||||
bpy.ops.bim.add_annotation()
|
||||
props = tool.Drawing.get_annotation_props()
|
||||
if props.object_type in self._PARAMETRIC_DIMENSION_TYPES:
|
||||
if bpy.ops.bim.draw_parametric_dimension.poll():
|
||||
bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT")
|
||||
elif bpy.ops.bim.add_annotation.poll():
|
||||
bpy.ops.bim.add_annotation("INVOKE_DEFAULT")
|
||||
|
||||
def hotkey_S_E(self):
|
||||
if not bpy.context.active_object:
|
||||
|
||||
@@ -44,12 +44,8 @@ class ViewportData:
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
# Populate data BEFORE flipping is_loaded so a raising ``mode()``
|
||||
# call doesn't leave the class half-loaded (flag set, dict empty).
|
||||
# Subsequent items-callback invocations skip load() on a True flag
|
||||
# and would hit ``cls.data["mode"]`` → KeyError.
|
||||
cls.data = {"mode": cls.mode()}
|
||||
cls.is_loaded = True
|
||||
cls.data = {"mode": cls.mode()}
|
||||
|
||||
@classmethod
|
||||
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
@@ -80,9 +76,9 @@ class ViewportData:
|
||||
modes.append(edit_mode)
|
||||
elif element.is_a("IfcGridAxis"):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Parametric.is_roof(element):
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Parametric.is_railing(element):
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
modes.append(edit_mode)
|
||||
elif item_mode not in modes:
|
||||
modes.append(item_mode)
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
import gpu
|
||||
@@ -23,16 +25,15 @@ import ifcopenshell
|
||||
import numpy as np
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class ItemDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw", "POST_VIEW"),
|
||||
)
|
||||
class ItemDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
objs: dict[str, dict[str, list]]
|
||||
obj_is_selected: dict[str, bool]
|
||||
obj_is_boolean: dict[str, list[ifcopenshell.entity_instance]]
|
||||
@@ -118,6 +119,23 @@ class ItemDecorator(tool.Blender.ViewportDecorator):
|
||||
"special_edges": special_edges,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_text(self, context):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
selected_elements_color = self.addon_prefs.decorator_color_selected
|
||||
@@ -145,6 +163,11 @@ class ItemDecorator(tool.Blender.ViewportDecorator):
|
||||
blf.disable(font_id, blf.SHADOW)
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
def transparent_color(color: Sequence[float], alpha: float = 0.05) -> list[float]:
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
selected_elements_color = self.addon_prefs.decorator_color_selected
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
@@ -174,33 +197,15 @@ class ItemDecorator(tool.Blender.ViewportDecorator):
|
||||
if context.mode != "OBJECT":
|
||||
continue
|
||||
self.draw_batch("LINES", data["verts"], selected_elements_color, data["edges"])
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(selected_elements_color, alpha=0.05),
|
||||
data["tris"],
|
||||
)
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(selected_elements_color), data["tris"])
|
||||
self.draw_batch("LINES", data["special_verts"], selected_elements_color, data["special_edges"])
|
||||
elif self.obj_is_boolean[obj_name]:
|
||||
self.draw_batch("LINES", data["verts"], special_elements_color, data["edges"])
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
|
||||
data["tris"],
|
||||
)
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
|
||||
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
|
||||
else:
|
||||
self.draw_batch(
|
||||
"LINES",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(unselected_elements_color, alpha=0.2),
|
||||
data["edges"],
|
||||
)
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
|
||||
data["tris"],
|
||||
"LINES", data["verts"], transparent_color(unselected_elements_color, alpha=0.2), data["edges"]
|
||||
)
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
|
||||
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
|
||||
|
||||
@@ -60,7 +60,6 @@ import bonsai.core.root
|
||||
import bonsai.core.spatial
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -546,13 +545,6 @@ 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
|
||||
@@ -890,16 +882,6 @@ 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):
|
||||
@@ -941,7 +923,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, batch_being_deleted_ids=batch_being_deleted_ids)
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
elif tool.Geometry.is_representation_item(obj):
|
||||
tool.Geometry.delete_ifc_item(obj)
|
||||
else:
|
||||
@@ -1040,17 +1022,14 @@ class OverrideDelete(bpy.types.Operator):
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
continue
|
||||
try:
|
||||
array_parents.add(ifc_file.by_guid(pset["Parent"]))
|
||||
except RuntimeError:
|
||||
continue
|
||||
array_parents.add(ifc_file.by_guid(pset["Parent"]))
|
||||
|
||||
for array_parent in array_parents:
|
||||
array_parent_obj = tool.Ifc.get_object(array_parent)
|
||||
data = [(i, data) for i, data in enumerate(tool.Array.get_modifiers_data(array_parent))]
|
||||
data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.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.Array.get_children_objects(modifier_data))
|
||||
children = set(tool.Blender.Modifier.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)
|
||||
@@ -1204,7 +1183,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
operator: bpy.types.Operator, context: bpy.types.Context, linked: bool = False
|
||||
) -> set["rna_enums.OperatorReturnItems"]:
|
||||
# Deep magick from the dawn of time
|
||||
if tool.Ifc.get() and tool.Model.has_selected_ifc_objects(include_active=False):
|
||||
if tool.Ifc.get():
|
||||
IfcStore.execute_ifc_operator(operator, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1308,9 +1287,6 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
if part_obj:
|
||||
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))
|
||||
|
||||
# Deselect everything first
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
|
||||
@@ -2247,8 +2223,6 @@ class OverrideEscape(bpy.types.Operator):
|
||||
bpy.ops.bim.hide_all_openings()
|
||||
elif tool.Aggregate.get_aggregate_props().in_aggregate_mode:
|
||||
bpy.ops.bim.disable_aggregate_mode()
|
||||
elif preview_base.try_cancel_active_preview(context):
|
||||
pass
|
||||
elif active_object := context.active_object:
|
||||
if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object):
|
||||
pass
|
||||
@@ -2290,8 +2264,6 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
gprops = tool.Geometry.get_geometry_props()
|
||||
if gprops.representation_obj:
|
||||
tool.Geometry.disable_item_mode()
|
||||
if active_obj := bpy.context.active_object:
|
||||
active_obj.select_set(False)
|
||||
else:
|
||||
bonsai.core.aggregate.exit_aggregate_mode(tool.Aggregate)
|
||||
return {"FINISHED"}
|
||||
@@ -2378,7 +2350,6 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
and usage in ("LAYER1", "LAYER2")
|
||||
):
|
||||
self.report({"INFO"}, f"Parametric {usage} elements cannot be edited directly")
|
||||
obj.select_set(False)
|
||||
elif item.is_a("IfcSweptAreaSolid"):
|
||||
tool.Geometry.sync_item_positions()
|
||||
res = tool.Model.import_profile((profile := item.SweptArea), obj=obj)
|
||||
@@ -2387,7 +2358,6 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
{"INFO"},
|
||||
f"Couldn't import profile, editing it directly is not yet supported. Failing profile: {profile}.",
|
||||
)
|
||||
obj.select_set(False)
|
||||
return
|
||||
tool.Ifc.link(item, obj.data)
|
||||
self.enable_edit_mode(context)
|
||||
@@ -2515,9 +2485,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.Parametric.is_railing(element):
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
bpy.ops.bim.finish_editing_railing_path()
|
||||
elif tool.Parametric.is_roof(element):
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
bpy.ops.bim.finish_editing_roof_path()
|
||||
elif tool.Model.get_usage_type(element) == "PROFILE":
|
||||
bpy.ops.bim.edit_extrusion_axis()
|
||||
@@ -3186,7 +3156,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
|
||||
product_reps = element.RepresentationMaps
|
||||
item_aspect = {}
|
||||
for product_rep in product_reps:
|
||||
for aspect in getattr(product_rep, "HasShapeAspects", ()):
|
||||
for aspect in product_rep.HasShapeAspects:
|
||||
for aspect_rep in aspect.ShapeRepresentations:
|
||||
if aspect_rep.ContextOfItems != representation.ContextOfItems:
|
||||
continue
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import bonsai.bim
|
||||
@@ -484,32 +483,10 @@ class BIM_PT_placement(Panel):
|
||||
row.label(text="No Object Placement Found")
|
||||
return
|
||||
|
||||
is_imperial = False
|
||||
if tool.Ifc.get():
|
||||
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
|
||||
if length_unit and length_unit.Name != "METRE":
|
||||
is_imperial = True
|
||||
|
||||
row = self.layout.row()
|
||||
row.label(text="Location:")
|
||||
|
||||
if is_imperial:
|
||||
loc = context.active_object.location
|
||||
for i, (axis, comp) in enumerate(zip("XYZ", (loc.x, loc.y, loc.z))):
|
||||
split = self.layout.split(factor=0.6)
|
||||
split.prop(context.active_object, "location", index=i, text=axis)
|
||||
sub = split.row()
|
||||
sub.enabled = False
|
||||
sub.alignment = "LEFT"
|
||||
sub.label(text=tool.Unit.format_distance(comp))
|
||||
else:
|
||||
for i, axis in enumerate("XYZ"):
|
||||
self.layout.prop(context.active_object, "location", index=i, text=axis)
|
||||
|
||||
row.prop(context.active_object, "location", text="Location")
|
||||
row = self.layout.row()
|
||||
row.label(text="Rotation:")
|
||||
for i, axis in enumerate("XYZ"):
|
||||
self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis)
|
||||
row.prop(context.active_object, "rotation_euler", text="Rotation")
|
||||
|
||||
if props.blender_offset_type != "NONE":
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
@@ -21,6 +21,7 @@ from math import radians
|
||||
import blf
|
||||
import gpu
|
||||
import ifcopenshell.util.geolocation
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Matrix, Vector
|
||||
@@ -29,11 +30,27 @@ import bonsai.tool as tool
|
||||
from bonsai.bim.module.georeference.data import GeoreferenceData
|
||||
|
||||
|
||||
class GeoreferenceDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw_geometry", "POST_VIEW"),
|
||||
)
|
||||
class GeoreferenceDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
@@ -180,10 +197,6 @@ class GeoreferenceDecorator(tool.Blender.ViewportDecorator):
|
||||
decorator_color_error = self.addon_prefs.decorator_color_error
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
# The georef gizmo is a coordinate-system overlay: it must communicate
|
||||
# orientation regardless of model contents, so depth testing is bypassed.
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
gpu.state.depth_test_set("ALWAYS")
|
||||
|
||||
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
@@ -322,8 +335,6 @@ class GeoreferenceDecorator(tool.Blender.ViewportDecorator):
|
||||
self.draw_batch("LINES", verts, decorator_color_special, edges)
|
||||
self.draw_dashed_line(location * 3, location * 6, decorator_color_error)
|
||||
|
||||
gpu.state.depth_test_set(original_depth_test)
|
||||
|
||||
def draw_dashed_line(self, start, end, colour, should_scale=True):
|
||||
direction = (end - start).normalized()
|
||||
distance = (end - start).length
|
||||
|
||||
@@ -20,18 +20,44 @@
|
||||
import blf
|
||||
import bpy
|
||||
import gpu
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.light.data import SolarData
|
||||
|
||||
|
||||
class SolarDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw_geometry", "POST_VIEW"),
|
||||
)
|
||||
class SolarDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_text(self, context: bpy.types.Context) -> None:
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
@@ -637,16 +637,6 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
usage=material_set_usage,
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
for obj in objects:
|
||||
obj_element = tool.Ifc.get_entity(obj)
|
||||
if not obj_element:
|
||||
continue
|
||||
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)
|
||||
if obj_material_usage and obj_material_usage.is_a("IfcMaterialProfileSetUsage"):
|
||||
obj_material_usage.CardinalPoint = material_set_usage.CardinalPoint
|
||||
obj_material_usage.ReferenceExtent = material_set_usage.ReferenceExtent
|
||||
|
||||
model_profile.DumbProfileRecalculator().recalculate(objects)
|
||||
|
||||
bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name)
|
||||
@@ -814,6 +804,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
slab.DumbSlabPlaner().regenerate_from_layer(layer)
|
||||
wall.DumbWallPlaner().regenerate_from_layer(layer)
|
||||
from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer
|
||||
regenerate_dims_for_layer(self.file, layer)
|
||||
elif material.is_a("IfcMaterialProfileSet"):
|
||||
profile_def = None
|
||||
if mprops.profiles:
|
||||
|
||||
@@ -15,15 +15,11 @@
|
||||
#
|
||||
# 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 typing import NamedTuple
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import (
|
||||
array,
|
||||
covering,
|
||||
@@ -31,9 +27,7 @@ from . import (
|
||||
external,
|
||||
grid,
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
mep_bend_preview,
|
||||
opening,
|
||||
product,
|
||||
profile,
|
||||
@@ -52,28 +46,17 @@ from . import (
|
||||
|
||||
classes = (
|
||||
array.AddArray,
|
||||
array.CancelEditingArray,
|
||||
array.DisableEditingArray,
|
||||
array.EditArray,
|
||||
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,
|
||||
@@ -85,51 +68,21 @@ classes = (
|
||||
product.SetActiveType,
|
||||
workspace.Hotkey,
|
||||
workspace.BIM_MT_add_representation_item,
|
||||
wall.AddPerpendicularWall,
|
||||
wall.AddWallsFromSlab,
|
||||
wall.AlignWall,
|
||||
wall.CancelEditingWall,
|
||||
wall.ChangeExtrusionDepth,
|
||||
wall.ChangeExtrusionXAngle,
|
||||
wall.ChangeLayerLength,
|
||||
wall.CycleWallOffset,
|
||||
wall.DrawPolylineWall,
|
||||
wall.EnableEditingWall,
|
||||
wall.ExtendWallHeightToCursor,
|
||||
wall.ExtendWallsToUnderside,
|
||||
wall.RegenerateWallToUnderside,
|
||||
wall.ExtendWallsToWall,
|
||||
wall.ExtendWallsToPolylinePoint,
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
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,
|
||||
wall.OffsetWalls,
|
||||
wall.RecalculateWall,
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.DisconnectElements,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
wall.CancelWallFilletPreview,
|
||||
wall.EnableWallFilletPreviewFromCorner,
|
||||
wall.CreateWallFillet,
|
||||
opening.AddBoolean,
|
||||
opening.CloneOpening,
|
||||
opening.EditOpenings,
|
||||
@@ -141,7 +94,6 @@ classes = (
|
||||
opening.RemoveBoolean,
|
||||
opening.SelectBoolean,
|
||||
opening.ShowOpenings,
|
||||
opening.ToggleHostOpenings,
|
||||
opening.UpdateOpeningsFocus,
|
||||
profile.ChangeCardinalPoint,
|
||||
profile.ChangeProfileDepth,
|
||||
@@ -157,14 +109,11 @@ 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,
|
||||
@@ -191,19 +140,10 @@ 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,
|
||||
ui.BIM_PT_sverchok,
|
||||
ui.BIM_PT_window,
|
||||
ui.BIM_PT_door,
|
||||
@@ -224,8 +164,7 @@ classes = (
|
||||
stair.ToggleStairProperty,
|
||||
stair.AdjustStairTreads,
|
||||
stair.SetStairTreads,
|
||||
stair.InputStairTreads,
|
||||
stair.PickStairType,
|
||||
stair.CycleStairType,
|
||||
stair.GizmoStairEdition,
|
||||
sverchok_modifier.CreateNewSverchokGraph,
|
||||
sverchok_modifier.UpdateDataFromSverchok,
|
||||
@@ -238,7 +177,7 @@ classes = (
|
||||
window.FinishEditingWindow,
|
||||
window.EnableEditingWindow,
|
||||
window.RemoveWindow,
|
||||
window.PickWindowType,
|
||||
window.CycleWindowType,
|
||||
window.GizmoWindowEdition,
|
||||
door.BIM_OT_add_door,
|
||||
door.AddDoor,
|
||||
@@ -247,19 +186,15 @@ classes = (
|
||||
door.EnableEditingDoor,
|
||||
door.RemoveDoor,
|
||||
door.ToggleDoorSwing,
|
||||
door.PickDoorType,
|
||||
door.CycleDoorType,
|
||||
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,
|
||||
@@ -268,39 +203,16 @@ 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,
|
||||
)
|
||||
|
||||
@@ -352,17 +264,15 @@ def register():
|
||||
bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties)
|
||||
bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties)
|
||||
bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties)
|
||||
bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties)
|
||||
bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties)
|
||||
# Per-parametric-type ``BIM<Name>Properties`` PointerProperties — driven by
|
||||
# ``tool.Parametric.EDIT_TYPES``; adding a registry entry is the single touchpoint.
|
||||
tool.Parametric.register_object_properties(prop)
|
||||
bpy.types.Object.BIMWindowProperties = bpy.props.PointerProperty(type=prop.BIMWindowProperties)
|
||||
bpy.types.Object.BIMDoorProperties = bpy.props.PointerProperty(type=prop.BIMDoorProperties)
|
||||
bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties)
|
||||
bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties)
|
||||
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
|
||||
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)
|
||||
@@ -371,17 +281,6 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
# DecorationsHandler is installed lazily by bim.show_openings; tear it down
|
||||
# (along with its persistent depsgraph / undo / redo / load cache handlers)
|
||||
# before the rest of unregister so those handlers can't fire against
|
||||
# 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)
|
||||
@@ -389,11 +288,13 @@ def unregister():
|
||||
del bpy.types.Scene.BIMModelProperties
|
||||
del bpy.types.Scene.BIMPolylineProperties
|
||||
del bpy.types.Object.BIMArrayProperties
|
||||
del bpy.types.Object.BIMStairProperties
|
||||
del bpy.types.Object.BIMSverchokProperties
|
||||
tool.Parametric.unregister_object_properties()
|
||||
del bpy.types.Object.BIMWindowProperties
|
||||
del bpy.types.Object.BIMDoorProperties
|
||||
del bpy.types.Object.BIMRailingProperties
|
||||
del bpy.types.Object.BIMRoofProperties
|
||||
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,10 +51,6 @@ 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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,9 +37,7 @@ 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, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMDoorProperties
|
||||
@@ -568,58 +566,103 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class _DoorEditMixin(FeatureModifierEditMixin):
|
||||
"""Type-specific hooks for door parametric-edit operators. Multi-object —
|
||||
iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies
|
||||
to every selected door at once."""
|
||||
|
||||
pset_name = "BBIM_Door"
|
||||
|
||||
@classmethod
|
||||
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
|
||||
return tool.Blender.get_selected_objects()
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_door_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_door_modifier_representation(obj)
|
||||
|
||||
|
||||
class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_door"
|
||||
bl_label = "Cancel Editing Door on Selected Objects"
|
||||
bl_description = "Cancel editing and revert door parameters to their previous values"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cancel_targets(context)
|
||||
def cancel_editing_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):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
core.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=body,
|
||||
)
|
||||
|
||||
props.is_editing = False
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
self.cancel_editing_door_on_object(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_door"
|
||||
bl_label = "Finish Editing Door on Selected Objects"
|
||||
bl_description = "Apply changes and finish editing door parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._finish_targets(context)
|
||||
def finish_editing_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):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
|
||||
door_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
|
||||
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
|
||||
|
||||
door_data["lining_properties"] = lining_props
|
||||
door_data["panel_properties"] = panel_props
|
||||
|
||||
props.is_editing = False
|
||||
|
||||
update_door_modifier_representation(obj)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
tool.Model.mark_thumbnail_for_update(element_type)
|
||||
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
|
||||
door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
self.finish_editing_door_on_object(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_door"
|
||||
bl_label = "Enable Editing Door on Selected Objects"
|
||||
bl_description = "Enter edit mode to modify door parameters interactively"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._enable_targets(context)
|
||||
def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
data.update(tool.Model.get_constituents_props_data(element))
|
||||
|
||||
# required since we could load pset from .ifc and BIMDoorProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
self.edit_door_on_obj(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -630,7 +673,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.Parametric.is_door(element):
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
props.is_editing = False
|
||||
@@ -645,8 +688,12 @@ 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 = "Change Door Swing"
|
||||
bl_label = "Toggle Door Swing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
|
||||
@@ -657,15 +704,6 @@ 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)
|
||||
@@ -692,7 +730,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"CANCELLED"}
|
||||
|
||||
is_door = tool.Parametric.is_door(element)
|
||||
is_door = tool.Blender.Modifier.is_door(element)
|
||||
|
||||
if self.flip_geometry:
|
||||
tool.Geometry.flip_object(obj, self.flip_local_axes)
|
||||
@@ -706,20 +744,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a door type from a popup menu."""
|
||||
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available door types. Shift+click to cycle in reverse."""
|
||||
|
||||
bl_idname = "bim.pick_door_type"
|
||||
bl_label = "Pick Door Type"
|
||||
bl_idname = "bim.cycle_door_type"
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_door
|
||||
props_getter = tool.Model.get_door_props
|
||||
element_checker = "is_door"
|
||||
props_getter = "get_door_props"
|
||||
type_literal = tool.Model.DoorType
|
||||
type_attr = "door_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._pick_type(context)
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
@@ -732,7 +770,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"
|
||||
pick_type_operator = "bim.pick_door_type"
|
||||
cycle_type_operator = "bim.cycle_door_type"
|
||||
|
||||
# Declarative dimension gizmo configuration with visibility and position
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
@@ -839,44 +877,14 @@ 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
|
||||
props_getter = "get_door_props"
|
||||
gizmo_pref_name = "door"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_door(element)
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for door icon positioning.
|
||||
@@ -894,20 +902,24 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
return (furthest_y, furthest_y)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
|
||||
|
||||
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."""
|
||||
"""Create door-specific swing arc gizmos."""
|
||||
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)
|
||||
inactive_color = prefs.decorator_color_background[:3]
|
||||
special_color = prefs.decorator_color_special[:3]
|
||||
|
||||
self.gizmo_door_type = self.create_arc_gizmo(
|
||||
special_color,
|
||||
"bim.toggle_door_swing",
|
||||
prop_path="BIMDoorProperties.door_type",
|
||||
flip_geometry=False,
|
||||
)
|
||||
self.gizmo_flip_arc = self.create_arc_gizmo(
|
||||
inactive_color,
|
||||
"bim.toggle_door_swing",
|
||||
prop_path="BIMDoorProperties.door_type",
|
||||
flip_geometry=True,
|
||||
flip_local_axes="XY",
|
||||
)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
|
||||
@@ -926,23 +938,29 @@ 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:
|
||||
"""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
|
||||
"""Update swing gizmo position and color based on editing state."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
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
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# 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.opening import is_filling_supported
|
||||
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 is_supported_filling_or_opening(element) -> bool:
|
||||
"""Total predicate for the add-opening gizmo poll. ``None`` (raw Blender
|
||||
mesh) is accepted because the operator converts unclassified meshes
|
||||
into ``IfcOpeningElement`` instances. ``IfcOpeningElement`` is accepted
|
||||
because reassigning an existing opening to a new host is a legal path
|
||||
through the operator. Otherwise defer to the generator's own
|
||||
supported-filling predicate."""
|
||||
if element is None:
|
||||
return True
|
||||
if element.is_a("IfcOpeningElement"):
|
||||
return True
|
||||
return is_filling_supported(element)
|
||||
|
||||
|
||||
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 exactly two objects are selected and one is a fillable
|
||||
host (wall / slab / roof) while the other is a valid filling (door /
|
||||
window / existing opening, or a plain Blender mesh).
|
||||
|
||||
Selection-order independent: the host role is identified by class, not
|
||||
by active state. The "+" icon anchors on the host's surface regardless
|
||||
of which object was clicked first. The dispatched ``bim.add_opening``
|
||||
operator also handles either order.
|
||||
|
||||
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:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return False
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return False
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return False
|
||||
a_element = tool.Ifc.get_entity(selected[0])
|
||||
b_element = tool.Ifc.get_entity(selected[1])
|
||||
return cls._is_apply_opening_pair(a_element, b_element) or cls._is_apply_opening_pair(b_element, a_element)
|
||||
|
||||
@staticmethod
|
||||
def _is_apply_opening_pair(host_element, filling_element) -> bool:
|
||||
"""``host_element`` qualifies as a fillable host AND ``filling_element``
|
||||
qualifies as a filling. Used twice with the operands swapped so the
|
||||
gizmo polls true regardless of which of the two selected objects is
|
||||
active."""
|
||||
if not is_supported_host(host_element):
|
||||
return False
|
||||
if not hasattr(host_element, "HasOpenings"):
|
||||
return False
|
||||
return is_supported_filling_or_opening(filling_element)
|
||||
|
||||
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:
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return
|
||||
a, b = selected[0], selected[1]
|
||||
a_element = tool.Ifc.get_entity(a)
|
||||
b_element = tool.Ifc.get_entity(b)
|
||||
if is_supported_host(a_element):
|
||||
host_obj, host_element, other = a, a_element, b
|
||||
elif is_supported_host(b_element):
|
||||
host_obj, host_element, other = b, b_element, a
|
||||
else:
|
||||
return
|
||||
|
||||
if tool.Parametric.is_path_connectable_wall(host_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
@@ -1,444 +0,0 @@
|
||||
# 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,8 +15,6 @@
|
||||
#
|
||||
# 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
|
||||
@@ -43,211 +41,8 @@ from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import decorator_cache
|
||||
from bonsai.bim.module.drawing.decoration import DecoratorData
|
||||
|
||||
# Multi-entry cache for the opening preview's dissolved-edges fallback.
|
||||
# Single-entry wouldn't fit: the draw handler iterates every active opening
|
||||
# per frame, each with its own mesh. Bumped wholesale on the shared
|
||||
# decorator-cache token (depsgraph / undo / redo / load), one slot per
|
||||
# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below —
|
||||
# consulted only on world-draw-data miss, so the global wipe rarely fires in
|
||||
# steady state and the simpler invalidation is enough.
|
||||
_dissolved_edges_cache: dict[
|
||||
tuple[int, float],
|
||||
tuple[list[Vector], list[tuple[int, int]]],
|
||||
] = {}
|
||||
_dissolved_edges_cache_token: int = -1
|
||||
|
||||
|
||||
def _get_cached_dissolved_edges(
|
||||
mesh: bpy.types.Mesh,
|
||||
angle_limit: float = radians(1.0),
|
||||
) -> tuple[list[Vector], list[tuple[int, int]]]:
|
||||
global _dissolved_edges_cache_token
|
||||
token = decorator_cache.get_decorator_cache_token()
|
||||
if token != _dissolved_edges_cache_token:
|
||||
_dissolved_edges_cache.clear()
|
||||
_dissolved_edges_cache_token = token
|
||||
key = (mesh.session_uid, angle_limit)
|
||||
cached = _dissolved_edges_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit)
|
||||
_dissolved_edges_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
# Per-object epoch: bumped only when this specific object's transform or geometry
|
||||
# updates land in the depsgraph delta. Invalidation work scales with the number
|
||||
# of changed objects, not total scene size — moving one object leaves every
|
||||
# other entry valid. Bumped by the depsgraph handler below; cleared on
|
||||
# undo/redo/load alongside the cache dicts.
|
||||
_object_epochs: dict[int, int] = {}
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _bump_object_epochs_for_decoration(*args) -> None:
|
||||
# depsgraph_update_post is called as (scene, depsgraph) in 4.x but the
|
||||
# *args signature follows decorator_cache's defensive idiom.
|
||||
depsgraph = args[1] if len(args) >= 2 else None
|
||||
if depsgraph is None or not hasattr(depsgraph, "updates"):
|
||||
return
|
||||
for u in depsgraph.updates:
|
||||
if not isinstance(u.id, bpy.types.Object):
|
||||
continue
|
||||
if not (u.is_updated_geometry or u.is_updated_transform):
|
||||
continue
|
||||
# u.id is the evaluated COW copy; the cache keys are written from the
|
||||
# original Object (read by the draw handler), and session_uid can
|
||||
# differ across the COW boundary. Resolve to the original before keying.
|
||||
original = getattr(u.id, "original", u.id)
|
||||
if original is None:
|
||||
continue
|
||||
uid = original.session_uid
|
||||
_object_epochs[uid] = _object_epochs.get(uid, 0) + 1
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _clear_decoration_caches_globally(*args) -> None:
|
||||
# Undo/redo/load: depsgraph deltas can't be trusted to describe the
|
||||
# transition, so wipe every per-object cache state.
|
||||
_object_epochs.clear()
|
||||
_world_draw_data_cache.clear()
|
||||
_batch_cache.clear()
|
||||
|
||||
|
||||
def _decoration_invalidation_hooks() -> tuple:
|
||||
return (
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
|
||||
def install_decoration_cache_handlers() -> None:
|
||||
if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration)
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
if _clear_decoration_caches_globally not in hook:
|
||||
hook.append(_clear_decoration_caches_globally)
|
||||
|
||||
|
||||
def uninstall_decoration_cache_handlers() -> None:
|
||||
try:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration)
|
||||
except ValueError:
|
||||
pass
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
try:
|
||||
hook.remove(_clear_decoration_caches_globally)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered),
|
||||
# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are
|
||||
# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a
|
||||
# stale entry for an object that didn't change since the last build still hits.
|
||||
_world_draw_data_cache: dict[
|
||||
int,
|
||||
tuple[
|
||||
int,
|
||||
tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
],
|
||||
],
|
||||
] = {}
|
||||
|
||||
|
||||
def _get_cached_world_draw_data(
|
||||
obj: bpy.types.Object,
|
||||
) -> tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
]:
|
||||
uid = obj.session_uid
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _world_draw_data_cache.get(uid)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
|
||||
mw = obj.matrix_world
|
||||
verts = [tuple(mw @ v.co) for v in obj.data.vertices]
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
|
||||
ios_edges_attribute = obj.data.attributes.get("ios_edges")
|
||||
if ios_edges_attribute:
|
||||
# Loader-curated edges: read the attribute aligned with bm.edges order.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
edges_indices = [
|
||||
tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value
|
||||
]
|
||||
bm.free()
|
||||
line_verts = verts
|
||||
else:
|
||||
dissolved, edges_indices = _get_cached_dissolved_edges(obj.data)
|
||||
line_verts = [tuple(mw @ v) for v in dissolved]
|
||||
|
||||
result = (line_verts, verts, edges_indices, tris)
|
||||
_world_draw_data_cache[uid] = (epoch, result)
|
||||
return result
|
||||
|
||||
|
||||
# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch);
|
||||
# lookup compares epoch to _object_epochs[uid] so other objects' batches stay
|
||||
# alive when one object's depsgraph delta bumps only its own epoch. The cached
|
||||
# batches reference GPU-side buffers tied to Blender's built-in shaders, which
|
||||
# are themselves cached by name (gpu.shader.from_builtin returns the same
|
||||
# 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]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _batch_cache.get(cache_key)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
|
||||
def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None:
|
||||
uid = cache_key[0]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
_batch_cache[cache_key] = (epoch, batch)
|
||||
|
||||
|
||||
def is_filling_supported(element) -> bool:
|
||||
"""True when Bonsai's opening generator can derive an opening from this
|
||||
element. IFC's schema permits any IfcElement as a filling; Bonsai
|
||||
currently supports only IfcDoor and IfcWindow because those are the
|
||||
classes with OverallWidth/OverallHeight attributes (or their types'
|
||||
ELEVATION_VIEW profiles) that the generator can consume."""
|
||||
return element is not None and element.is_a() in ("IfcDoor", "IfcWindow")
|
||||
|
||||
|
||||
class FilledOpeningGenerator:
|
||||
def generate(
|
||||
@@ -255,15 +50,9 @@ 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()
|
||||
@@ -285,7 +74,7 @@ class FilledOpeningGenerator:
|
||||
should_set_z_level = False
|
||||
|
||||
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
|
||||
if not preserve_placement and voided_obj.data:
|
||||
if 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()
|
||||
@@ -418,16 +207,18 @@ class FilledOpeningGenerator:
|
||||
representation = tool.Geometry.get_representation_by_context(voided_element, context)
|
||||
assert representation
|
||||
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=voided_obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
def regenerate_from_type(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
|
||||
relating_type = settings["relating_type"]
|
||||
|
||||
# Filling type-switch on an array of fillings fans out N host recuts —
|
||||
# one per related object — without batching. Coalesce them.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
for related_object in settings["related_objects"]:
|
||||
self._regenerate_from_type(related_object)
|
||||
for related_object in settings["related_objects"]:
|
||||
self._regenerate_from_type(related_object)
|
||||
|
||||
def _regenerate_from_type(self, related_object: ifcopenshell.entity_instance) -> None:
|
||||
filling = related_object
|
||||
@@ -476,7 +267,12 @@ class FilledOpeningGenerator:
|
||||
representation = tool.Geometry.get_active_representation(voided_obj)
|
||||
if not representation:
|
||||
continue
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=voided_obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
def generate_opening_from_filling(
|
||||
self,
|
||||
@@ -611,31 +407,6 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return context.selected_objects
|
||||
|
||||
def _execute(self, context):
|
||||
# N selected fillings × M voided host parts would fire N×M host recuts
|
||||
# without batching. Coalesce per host.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
return self._recalculate_fills(context)
|
||||
|
||||
def _recalculate_fills(self, context):
|
||||
# Refresh each selected filling's mapped opening source before
|
||||
# recutting the host. Dedup by source id covers the common shared-
|
||||
# source case in one rewrite while leaving unrelated sibling sources
|
||||
# untouched.
|
||||
seen_source_ids: set[int] = set()
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.FillsVoids:
|
||||
continue
|
||||
opening = element.FillsVoids[0].RelatingOpeningElement
|
||||
body = tool.Geometry.get_body_representation(opening)
|
||||
if body is None:
|
||||
continue
|
||||
source = tool.Geometry.resolve_mapped_representation(body)
|
||||
if source.id() in seen_source_ids:
|
||||
continue
|
||||
seen_source_ids.add(source.id())
|
||||
tool.Model.regenerate_filling_opening_body(element)
|
||||
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.FillsVoids:
|
||||
@@ -664,7 +435,12 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if building_obj and building_obj.data:
|
||||
representation = tool.Geometry.get_active_representation(building_obj)
|
||||
if representation:
|
||||
tool.Geometry.recut_host(building_obj, representation)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=building_obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
# Refresh cut decorator
|
||||
DecoratorData.cut_cache.clear()
|
||||
@@ -782,29 +558,6 @@ 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"
|
||||
@@ -986,29 +739,27 @@ class EditOpenings(Operator, tool.Ifc.Operator):
|
||||
for opening_element in opening_elements:
|
||||
opening_obj = tool.Ifc.get_object(opening_element)
|
||||
|
||||
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
||||
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
||||
tool.Ifc, similar_openings
|
||||
)
|
||||
building_objs.update(similar_openings_building_objs)
|
||||
|
||||
if opening_obj:
|
||||
opening_edited = tool.Ifc.is_edited(opening_obj)
|
||||
opening_moved = tool.Ifc.is_moved(opening_obj)
|
||||
# Sibling walls only need a viewport-level refresh when the
|
||||
# opening's shape or placement actually changed — a pure
|
||||
# show/hide toggle leaves them in their existing state.
|
||||
if opening_edited or opening_moved:
|
||||
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
||||
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
||||
tool.Ifc, similar_openings
|
||||
)
|
||||
building_objs.update(similar_openings_building_objs)
|
||||
if opening_edited:
|
||||
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
||||
else:
|
||||
bonsai.core.geometry.edit_object_placement(
|
||||
tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
|
||||
)
|
||||
if tool.Ifc.is_edited(opening_obj):
|
||||
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
||||
bonsai.core.geometry.edit_similar_opening_placement(
|
||||
tool.Geometry, opening_element, similar_openings
|
||||
)
|
||||
elif tool.Ifc.is_moved(opening_obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj)
|
||||
bonsai.core.geometry.edit_similar_opening_placement(
|
||||
tool.Geometry, opening_element, similar_openings
|
||||
)
|
||||
building_objs.update(self.get_all_building_objects_of_similar_openings(opening_element))
|
||||
|
||||
building_objs.update(
|
||||
self.get_all_building_objects_of_similar_openings(opening_element)
|
||||
) # NB this has nothing to do with clone similar_opening
|
||||
tool.Ifc.unlink(element=opening_element)
|
||||
if props.representation_obj == opening_obj:
|
||||
props.representation_obj = None
|
||||
@@ -1046,12 +797,6 @@ class CloneOpening(Operator, tool.Ifc.Operator):
|
||||
return True
|
||||
|
||||
def _execute(self, context):
|
||||
# The voided host may be an aggregate whose parts each get recut.
|
||||
# Coalesce per host so a many-parts aggregate doesn't fan out.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
return self._clone_opening(context)
|
||||
|
||||
def _clone_opening(self, context):
|
||||
# NOTE: Operator displayed in UI only with IfcOpeningElement being active.
|
||||
ifc_file = tool.Ifc.get()
|
||||
objects = bpy.context.selected_objects
|
||||
@@ -1081,7 +826,12 @@ class CloneOpening(Operator, tool.Ifc.Operator):
|
||||
continue
|
||||
representation = tool.Geometry.get_active_representation(obj)
|
||||
assert representation
|
||||
tool.Geometry.recut_host(obj, representation)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1191,6 +941,7 @@ class SelectBoolean(Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# TODO: merge with ProfileDecorator?
|
||||
class DecorationsHandler:
|
||||
installed = None
|
||||
|
||||
@@ -1200,7 +951,6 @@ class DecorationsHandler:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")
|
||||
install_decoration_cache_handlers()
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
@@ -1209,79 +959,15 @@ class DecorationsHandler:
|
||||
except ValueError:
|
||||
pass
|
||||
cls.installed = None
|
||||
uninstall_decoration_cache_handlers()
|
||||
|
||||
def _get_or_build_batch(self, shader, shader_type, content_pos, indices=None, cache_key=None):
|
||||
if cache_key is not None:
|
||||
cached = _get_cached_batch_or_none(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return None
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
if cache_key is not None:
|
||||
_store_batch_in_cache(cache_key, batch)
|
||||
return batch
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None, cache_key=None):
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = self._get_or_build_batch(shader, shader_type, content_pos, indices, cache_key=cache_key)
|
||||
if batch is None:
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
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")
|
||||
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):
|
||||
props = tool.Model.get_model_props()
|
||||
if not props.openings:
|
||||
@@ -1315,7 +1001,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", _DEFAULT_LINE_WIDTH)
|
||||
self.line_shader.uniform_float("lineWidth", 2.0)
|
||||
|
||||
# general shader
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
@@ -1353,18 +1039,23 @@ 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)
|
||||
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)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
|
||||
verts = [tuple(obj.matrix_world @ v.co) for v in bm.verts]
|
||||
if ios_edges_attribute := obj.data.attributes.get("ios_edges"):
|
||||
edges = [e for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value]
|
||||
else:
|
||||
edges = bm.edges
|
||||
edges_indices = [tuple([v.index for v in e.verts]) for e in edges]
|
||||
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
self._draw_lines_with_occlusion(line_verts, color, edges_indices, cache_key=(obj.session_uid, "lines"))
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
verts,
|
||||
transparent_color(special_elements_color),
|
||||
tris,
|
||||
cache_key=(obj.session_uid, "tris"),
|
||||
)
|
||||
self.draw_batch("LINES", verts, color, edges_indices)
|
||||
|
||||
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)
|
||||
|
||||
if "HalfSpaceSolid" in obj.name:
|
||||
# Arrow shape
|
||||
@@ -1378,4 +1069,7 @@ class DecorationsHandler:
|
||||
]
|
||||
edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)]
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
|
||||
self.draw_batch("LINES", verts, color, edges)
|
||||
|
||||
if obj.mode != "EDIT":
|
||||
bm.free()
|
||||
|
||||
@@ -75,7 +75,6 @@ class PolylineOperator:
|
||||
self.is_typing = False
|
||||
self.snap_angle = None
|
||||
self.snapping_points = []
|
||||
self.unit_scale = 1.0
|
||||
self.instructions = {
|
||||
"Cycle Input": {"icons": True, "keys": ["EVENT_TAB"]},
|
||||
"Distance Input": {"icons": True, "keys": ["EVENT_D"]},
|
||||
@@ -462,14 +461,26 @@ class PolylineOperator:
|
||||
self.tool_state.axis_method = None
|
||||
self.tool_state.plane_method = None
|
||||
self.tool_state.mode = "Mouse"
|
||||
tool.Raycast.clear_snap_objs()
|
||||
# Do not call clear_snap_objs() here — create_snap_obj() validates stale
|
||||
# entries per-object (vertex count + position check), so the BVH cache can
|
||||
# safely persist across invocations. Clearing it caused an 11-second stall
|
||||
# on every Shift+A because SnapObj rebuilds a pure-Python BVH tree.
|
||||
self.visible_objs = tool.Raycast.get_visible_objects(context)
|
||||
for obj in self.visible_objs:
|
||||
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
|
||||
self.objs_2d_bbox.append(bbox_2d)
|
||||
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
|
||||
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
|
||||
self._init_snapping_points(context, event)
|
||||
tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
context.window_manager.modal_handler_add(self)
|
||||
|
||||
def _init_snapping_points(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
|
||||
"""Populate self.snapping_points at operator start.
|
||||
|
||||
Override in subclasses to skip the full BVH snap detection when a cheap
|
||||
placeholder is sufficient. The default runs the full detection pass.
|
||||
"""
|
||||
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
|
||||
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
|
||||
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
# 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 helpers for Bonsai's parametric preview flows.
|
||||
|
||||
Multiple Bonsai features follow the same Scene-level preview pattern:
|
||||
|
||||
Enable<X>Preview — validates a selection, populates draft state on
|
||||
``Scene.BIMPreviewProperties.<x>``, flips ``is_active``.
|
||||
Gizmo<X>Preview — polls on ``is_active``, surfaces tunable widgets +
|
||||
validate/cancel icons.
|
||||
<X>PreviewDecorator — GPU lines drawn while ``is_active`` is True.
|
||||
Finish<X>Preview — direct ``bpy.ops.bim.<verb>(...)`` call with kwargs
|
||||
read off the draft state, then clears it.
|
||||
Cancel<X>Preview — pure state reset.
|
||||
|
||||
The MEP bend and wall fillet flows are the two current callers. They write
|
||||
their Finish / Cancel operators directly, matching the convention used
|
||||
throughout the rest of ``bim/module/model/`` for operator-to-operator
|
||||
dispatch (explicit ``bpy.ops.bim.X(kwarg=value)`` at the call site, no
|
||||
string indirection). This module hosts the cross-cutting accessors only;
|
||||
no base class layer.
|
||||
|
||||
The GPU draw-handler lifecycle for ``<X>PreviewDecorator`` lives on the
|
||||
feature-neutral ``tool.Blender.ViewportDecorator`` base, which every
|
||||
viewport decorator (preview or otherwise) inherits from."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
# --- Props accessors ---------------------------------------------------------
|
||||
|
||||
|
||||
def get_preview_props(context: bpy.types.Context, attr: str):
|
||||
"""Resolve a child preview PropertyGroup under ``Scene.BIMPreviewProperties``.
|
||||
|
||||
Returns ``None`` if the umbrella isn't attached yet — true briefly
|
||||
during addon register and during plug-out, so polls / draw callbacks
|
||||
must defend against ``None`` rather than assuming the prop is always
|
||||
available. Also tolerates contexts without a ``scene`` attribute
|
||||
(test mocks built from ``SimpleNamespace``)."""
|
||||
scene = getattr(context, "scene", None)
|
||||
if scene is None:
|
||||
return None
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
|
||||
def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
|
||||
"""``True`` while a specific preview is open. Used by sibling gizmo
|
||||
polls to hide themselves so the preview is the only interactive
|
||||
surface in the viewport (the bend / fillet preview groups take over
|
||||
the same selection's icon stack)."""
|
||||
props = get_preview_props(context, attr)
|
||||
return bool(props is not None and props.is_active)
|
||||
|
||||
|
||||
def any_preview_active(context: bpy.types.Context) -> bool:
|
||||
"""``True`` if any registered preview is currently open. Sister gizmo
|
||||
polls call this to hide themselves uniformly during ANY preview, so a
|
||||
new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates
|
||||
every parametric gizmo without each one growing a specific check."""
|
||||
for attr, _op_name in PREVIEW_CANCEL_OPS:
|
||||
if is_preview_active(context, attr):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Lazy closure factories --------------------------------------------------
|
||||
#
|
||||
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
|
||||
# ``move_get_cb`` / ``move_set_cb`` callbacks. The closures re-resolve
|
||||
# ``bpy.context.scene`` per CALL rather than capturing it at setup() time
|
||||
# — the captured Scene's RNA struct can be freed on file open / undo, and
|
||||
# referencing a freed struct crashes Blender. Lazy lookup survives the
|
||||
# whole undo / reload lifecycle.
|
||||
|
||||
|
||||
def make_props_callback(attr: str) -> Callable[[], Any]:
|
||||
"""Return a zero-arg callable that lazily fetches the preview props.
|
||||
|
||||
Equivalent to ``getattr(bpy.context.scene.BIMPreviewProperties, attr)``
|
||||
with full defensiveness against missing scene / missing umbrella."""
|
||||
|
||||
def _props():
|
||||
scene = bpy.context.scene
|
||||
preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
return _props
|
||||
|
||||
|
||||
def make_dim_getter(props_callback: Callable[[], Any], field: str) -> Callable[[], float]:
|
||||
"""Factory for ``BIM_GT_gizmo_dimension.move_get_cb`` reading a single
|
||||
FloatProperty off the live preview state. Returns ``0.0`` defensively
|
||||
when the props are temporarily unavailable so the widget doesn't crash
|
||||
Blender during plug-out / reload."""
|
||||
|
||||
def _get() -> float:
|
||||
props = props_callback()
|
||||
return getattr(props, field) if props is not None else 0.0
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
def make_dim_setter(
|
||||
props_callback: Callable[[], Any],
|
||||
field: str,
|
||||
min_value: float = 0.001,
|
||||
) -> Callable[[float], None]:
|
||||
"""Factory for ``BIM_GT_gizmo_dimension.move_set_cb`` writing a single
|
||||
FloatProperty + tagging viewport areas for redraw so the GPU preview
|
||||
decorator tracks the value live during drag. Clamps at ``min_value``
|
||||
to match the FloatProperty's declared lower bound."""
|
||||
|
||||
def _set(value: float) -> None:
|
||||
props = props_callback()
|
||||
if props is None:
|
||||
return
|
||||
setattr(props, field, max(min_value, float(value)))
|
||||
tool.Blender.update_all_viewports()
|
||||
|
||||
return _set
|
||||
|
||||
|
||||
# --- Shared Enable lifecycle helpers -----------------------------------------
|
||||
|
||||
|
||||
def sync_uncommitted_moves(objects: list) -> None:
|
||||
"""Push any Blender-side translation / rotation of ``objects`` back to
|
||||
their IFC ``ObjectPlacement`` before a preview decorator starts reading
|
||||
``obj.matrix_world`` per frame.
|
||||
|
||||
Without this sync, a user who grabbed-moved an object but didn't commit
|
||||
the move sees the live preview at the dragged position while the final
|
||||
commit lands at the stale IFC position — a confusing "where did my
|
||||
preview go?" experience. Both bend and fillet enable paths call this
|
||||
on the relevant pair just before activating the preview."""
|
||||
for obj in objects:
|
||||
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], ...] = (
|
||||
("bend", "cancel_bend_preview"),
|
||||
("wall_fillet", "cancel_wall_fillet_preview"),
|
||||
)
|
||||
"""Registry of ``(child PointerProperty on Scene.BIMPreviewProperties, bim
|
||||
operator name)`` consulted by the Esc handler. Adding a new preview means
|
||||
appending one tuple; the forward-compat test pins that every preview
|
||||
PropertyGroup with ``is_active`` has an entry here."""
|
||||
|
||||
|
||||
def try_cancel_active_preview(context: bpy.types.Context) -> bool:
|
||||
"""Cancel every registered preview that is currently active.
|
||||
|
||||
Returns ``True`` iff at least one preview was cancelled. Multiple
|
||||
previews can be simultaneously active (e.g. a stale bend preview opened
|
||||
just before the user starts a wall fillet) — one Esc must clear them
|
||||
all rather than forcing the user to tap Esc once per preview.
|
||||
|
||||
Tags 3D viewports for redraw on success — the Esc keymap entry runs
|
||||
outside a viewport mouse event so the gizmo poll wouldn't re-evaluate
|
||||
until the next interaction without an explicit redraw."""
|
||||
cancelled = False
|
||||
for attr, op_name in PREVIEW_CANCEL_OPS:
|
||||
if is_preview_active(context, attr):
|
||||
getattr(bpy.ops.bim, op_name)()
|
||||
cancelled = True
|
||||
if cancelled:
|
||||
tool.Blender.update_all_viewports(context)
|
||||
return cancelled
|
||||
|
||||
|
||||
def discard_pending_previews(scene: bpy.types.Scene) -> None:
|
||||
"""Clear every active preview under ``Scene.BIMPreviewProperties`` so
|
||||
saved preview state never resurfaces on file load.
|
||||
|
||||
Mirrors ``tool.Parametric.heal_stale_edit_flags`` for the object-level
|
||||
parametric-edit lifecycle — except previews are *discarded* rather than
|
||||
validated. A preview's only UI cue is its in-viewport widget; reloading
|
||||
a ``.blend`` saved mid-preview restores the flag but not the surrounding
|
||||
user attention, and a stuck ``is_active`` silently hides every sibling
|
||||
gizmo poll gated on it.
|
||||
|
||||
Iterates ``PREVIEW_CANCEL_OPS`` so any preview registered for Esc
|
||||
cancellation is automatically covered here too. Sets ``is_active``
|
||||
directly rather than dispatching the cancel operator: load_post may
|
||||
fire before ``bpy.context.screen`` is reattached, and the cancel
|
||||
operators bail on ``context.screen is None``."""
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
if preview is None:
|
||||
return
|
||||
for attr, _op_name in PREVIEW_CANCEL_OPS:
|
||||
child = getattr(preview, attr, None)
|
||||
if child is not None and getattr(child, "is_active", False):
|
||||
child.is_active = False
|
||||
@@ -1002,14 +1002,6 @@ 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")
|
||||
@@ -1165,8 +1157,7 @@ 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_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
|
||||
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
|
||||
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
|
||||
)
|
||||
|
||||
def modal(self, context, event):
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
#
|
||||
# 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 math
|
||||
from collections.abc import Callable
|
||||
@@ -33,10 +31,8 @@ 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
|
||||
@@ -105,12 +101,8 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
|
||||
|
||||
|
||||
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
|
||||
# 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)
|
||||
bpy.ops.bim.enable_editing_array(item=self.is_editing)
|
||||
return
|
||||
|
||||
|
||||
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
|
||||
@@ -134,19 +126,6 @@ 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)
|
||||
@@ -214,36 +193,14 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None
|
||||
_get_updater("stair", "regenerate_stair_mesh")(obj)
|
||||
|
||||
|
||||
def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate wall mesh preview when property changes. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None:
|
||||
"""Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC.
|
||||
|
||||
``offset`` itself has no ``update`` callback on purpose — adding one would make
|
||||
every baseline cycle rebuild the bmesh twice (once via offset's callback, once
|
||||
explicitly below)."""
|
||||
obj = context.active_object
|
||||
if not (obj and self.is_editing):
|
||||
return
|
||||
t = self.thickness
|
||||
if self.desired_offset_baseline == "CENTER":
|
||||
self.offset = -t / 2
|
||||
elif self.desired_offset_baseline == "INTERIOR":
|
||||
self.offset = -t
|
||||
else: # EXTERIOR
|
||||
self.offset = 0.0
|
||||
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate railing mesh when property changes."""
|
||||
if self.is_editing:
|
||||
_get_updater("railing", "update_railing_modifier_bmesh")(context)
|
||||
# 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)
|
||||
|
||||
|
||||
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
||||
@@ -253,20 +210,6 @@ 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(
|
||||
@@ -369,19 +312,6 @@ 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")
|
||||
@@ -429,7 +359,6 @@ 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
|
||||
@@ -440,13 +369,8 @@ class BIMModelProperties(PropertyGroup):
|
||||
|
||||
|
||||
class BIMArrayProperties(PropertyGroup):
|
||||
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.",
|
||||
is_editing: bpy.props.IntProperty(
|
||||
default=-1, description="Currently edited array index. -1 if not in array editing mode."
|
||||
)
|
||||
count: bpy.props.IntProperty(name="Count", default=0, min=0)
|
||||
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
|
||||
@@ -462,15 +386,6 @@ 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",
|
||||
@@ -479,15 +394,13 @@ class BIMArrayProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
editing_item_index: int
|
||||
is_editing: 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]
|
||||
|
||||
@@ -1718,133 +1631,6 @@ 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.
|
||||
|
||||
Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
|
||||
(preview only — no IFC writes), and either committed by `bim.finish_editing_wall`
|
||||
or discarded by `bim.cancel_editing_wall`.
|
||||
|
||||
The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
|
||||
current vs snap to skip unchanged params and guarantee a no-op session leaves the
|
||||
IFC file byte-identical.
|
||||
"""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while wall 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 box; 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_wall,
|
||||
description="Wall length along its reference axis (preview value; committed on finish).",
|
||||
)
|
||||
height: bpy.props.FloatProperty(
|
||||
name="Height",
|
||||
default=3.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_wall,
|
||||
description="Wall vertical height (preview value; committed on finish).",
|
||||
)
|
||||
x_angle: bpy.props.FloatProperty(
|
||||
name="Slope (X Angle)",
|
||||
default=0.0,
|
||||
soft_min=-math.pi / 3,
|
||||
soft_max=math.pi / 3,
|
||||
subtype="ANGLE",
|
||||
update=update_wall,
|
||||
description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
|
||||
)
|
||||
thickness: bpy.props.FloatProperty(
|
||||
name="Thickness",
|
||||
default=0.2,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
|
||||
)
|
||||
offset: bpy.props.FloatProperty(
|
||||
name="Offset",
|
||||
default=0.0,
|
||||
subtype="DISTANCE",
|
||||
description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
|
||||
)
|
||||
desired_offset_baseline: bpy.props.EnumProperty(
|
||||
items=[
|
||||
("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
|
||||
("CENTER", "Center", "Reference axis at the wall centreline"),
|
||||
("INTERIOR", "Interior", "Reference axis at the interior face"),
|
||||
],
|
||||
name="Desired Offset Baseline",
|
||||
default="CENTER",
|
||||
update=update_wall_offset_baseline,
|
||||
description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
|
||||
)
|
||||
anchor_x: bpy.props.FloatProperty(
|
||||
default=0.0,
|
||||
subtype="DISTANCE",
|
||||
description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
|
||||
)
|
||||
|
||||
snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
|
||||
snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
|
||||
snap_thickness: bpy.props.FloatProperty(
|
||||
description="Snapshot of thickness at edit-enable; commit skips no-op writes."
|
||||
)
|
||||
snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
|
||||
snap_x_angle: bpy.props.FloatProperty(
|
||||
subtype="ANGLE",
|
||||
description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_offset_baseline: bpy.props.StringProperty(
|
||||
default="",
|
||||
description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
height: float
|
||||
x_angle: float
|
||||
thickness: float
|
||||
offset: float
|
||||
desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
|
||||
anchor_x: float
|
||||
snap_length: float
|
||||
snap_height: float
|
||||
snap_thickness: float
|
||||
snap_offset: float
|
||||
snap_x_angle: float
|
||||
snap_offset_baseline: str
|
||||
|
||||
|
||||
class SnapMousePoint(PropertyGroup):
|
||||
x: bpy.props.FloatProperty(name="X")
|
||||
y: bpy.props.FloatProperty(name="Y")
|
||||
@@ -1976,236 +1762,3 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
|
||||
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
|
||||
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.
|
||||
|
||||
Scene-level because the fillet spans two walls and commits a third
|
||||
(corner) wall between them. ``SKIP_SAVE`` fields throughout."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the wall-fillet preview flow is active.",
|
||||
)
|
||||
wall_a_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of the active wall — the corner wall inherits its "
|
||||
"material layer set, height, x_angle, and type."
|
||||
),
|
||||
)
|
||||
wall_b_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the other selected wall.",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.5,
|
||||
soft_min=-10.0,
|
||||
soft_max=10.0,
|
||||
subtype="DISTANCE",
|
||||
unit="LENGTH",
|
||||
options={"SKIP_SAVE"},
|
||||
description="Radius of the circular arc connecting the two walls.",
|
||||
)
|
||||
editing_corner_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing fillet corner being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this corner + its connections before recreating "
|
||||
"with the new radius."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
wall_a_id: int
|
||||
wall_b_id: int
|
||||
radius: float
|
||||
editing_corner_id: int
|
||||
|
||||
|
||||
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,7 +18,6 @@
|
||||
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import bmesh
|
||||
@@ -28,24 +27,13 @@ import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Matrix, Vector
|
||||
from mathutils import 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 (
|
||||
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
|
||||
@@ -104,6 +92,7 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None:
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
representation_data = {
|
||||
"railing_type": props.railing_type,
|
||||
"context": body,
|
||||
"railing_path": railing_path,
|
||||
"use_manual_supports": props.use_manual_supports,
|
||||
@@ -136,56 +125,6 @@ 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
|
||||
@@ -201,13 +140,6 @@ 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)
|
||||
@@ -233,6 +165,8 @@ 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)
|
||||
@@ -277,7 +211,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=WELD_TOLERANCE)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
|
||||
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||
|
||||
@@ -337,8 +271,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]:
|
||||
segments.append((i - 1, 0))
|
||||
break
|
||||
|
||||
# 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:
|
||||
# skip path verts if they just go vertical to avoid errors
|
||||
if (v.co.xy - prev_v.co.xy).length <= 0.0001:
|
||||
continue
|
||||
|
||||
points.append(v.co)
|
||||
@@ -472,626 +406,66 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class _RailingEditMixin(PathPreservingEditMixin):
|
||||
"""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.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_railing_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _post_load_data(cls, data: dict) -> dict:
|
||||
# BIMRailingProperties.path_data is a StringProperty holding JSON.
|
||||
data["path_data"] = json.dumps(data["path_data"])
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data: dict) -> None:
|
||||
update_bbim_railing_pset(element, data)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_railing_modifier_ifc_data(context)
|
||||
|
||||
@classmethod
|
||||
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)
|
||||
|
||||
|
||||
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_railing"
|
||||
bl_label = "Enable Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._enable_targets(context)
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
||||
data["path_data"] = json.dumps(data["path_data"])
|
||||
|
||||
# required since we could load pset from .ifc and BIMRailingProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_railing"
|
||||
bl_label = "Cancel Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_railing"
|
||||
bl_label = "Finish Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
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
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
|
||||
class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_railing"
|
||||
bl_label = "Cancel Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
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"}
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
|
||||
skip_element_check = True
|
||||
props_getter = tool.Model.get_railing_props
|
||||
type_literal = prop.CapType
|
||||
type_attr = "terminal_type"
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
update_railing_modifier_bmesh(context)
|
||||
|
||||
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)
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
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 FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_railing"
|
||||
bl_label = "Finish Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
|
||||
class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup):
|
||||
"""Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode."""
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing")
|
||||
path_data = pset_data["data_dict"]["path_data"]
|
||||
|
||||
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"}
|
||||
railing_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
railing_data["path_data"] = path_data
|
||||
props.is_editing = False
|
||||
|
||||
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"
|
||||
update_bbim_railing_pset(element, railing_data)
|
||||
update_railing_modifier_ifc_data(context)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -1137,16 +511,6 @@ 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 atan2, cos, degrees, pi, radians, tan
|
||||
from typing import Any, ClassVar, Literal, Union
|
||||
from math import cos, pi, radians, tan
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -32,11 +32,8 @@ 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 CycleTypeMixin, PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
|
||||
@@ -213,13 +210,7 @@ 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]
|
||||
# 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)]
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
|
||||
|
||||
if mode == "HEIGHT": # Calculate the angle we ended up with.
|
||||
new_faces[0].normal_update()
|
||||
@@ -405,11 +396,6 @@ 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
|
||||
|
||||
|
||||
@@ -622,169 +608,61 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tool.Model.add_body_representation(obj)
|
||||
|
||||
|
||||
class _RoofEditMixin(PathPreservingEditMixin):
|
||||
"""Type-specific hooks for roof parametric-edit operators. Single-object
|
||||
(active_object). ``path_data`` is preserved through the edit; the separate
|
||||
``Enable/Finish/CancelEditingRoofPath`` operators handle path editing."""
|
||||
class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
pset_name = "BBIM_Roof"
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
|
||||
# required since we could load pset from .ifc and BIMRoofProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_roof_props(obj)
|
||||
class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data: dict) -> None:
|
||||
update_bbim_roof_pset(element, data)
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
|
||||
path_data = pset_data["data_dict"]["path_data"]
|
||||
|
||||
roof_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
roof_data["path_data"] = path_data
|
||||
props.is_editing = False
|
||||
|
||||
update_bbim_roof_pset(element, roof_data)
|
||||
update_roof_modifier_ifc_data(context)
|
||||
|
||||
@classmethod
|
||||
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)
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
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 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"}
|
||||
|
||||
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.
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_roof_props
|
||||
gizmo_pref_name = "roof"
|
||||
|
||||
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)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
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 = tool.Model.get_existing_x_angle(extrusion)
|
||||
existing_x_angle = obj.rotation_euler.x
|
||||
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,14 +620,6 @@ 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")
|
||||
@@ -999,74 +991,3 @@ 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"}
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
#
|
||||
# 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 json
|
||||
|
||||
@@ -31,13 +29,7 @@ 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 (
|
||||
COLOR_GREEN,
|
||||
COLOR_RED,
|
||||
DimensionGizmoConfig,
|
||||
IconSlot,
|
||||
)
|
||||
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.tool.numeric_input import (
|
||||
IntegerInputState,
|
||||
run_integer_input_modal,
|
||||
@@ -45,7 +37,7 @@ from bonsai.tool.numeric_input import (
|
||||
)
|
||||
|
||||
V_ = tool.Blender.V_
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bmesh.types import BMVert
|
||||
from bpy.props import IntProperty
|
||||
@@ -270,6 +262,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
# Use the special method that includes custom_tread_lock for IFC storage
|
||||
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
|
||||
props.is_editing = False
|
||||
regenerate_stair_mesh(obj)
|
||||
tool.Model.add_body_representation(obj)
|
||||
|
||||
@@ -279,7 +272,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
# update IfcStairFlight properties
|
||||
update_ifc_stair_props(obj)
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -384,20 +376,6 @@ 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."""
|
||||
|
||||
@@ -443,20 +421,20 @@ class SetStairTreads(bpy.types.Operator):
|
||||
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
|
||||
|
||||
|
||||
class PickStairType(bpy.types.Operator, PickTypeMixin):
|
||||
"""Pick a stair type from a popup menu."""
|
||||
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through stair types. Shift+click to cycle in reverse."""
|
||||
|
||||
bl_idname = "bim.pick_stair_type"
|
||||
bl_label = "Pick Stair Type"
|
||||
bl_idname = "bim.cycle_stair_type"
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = tool.Model.get_stair_props
|
||||
props_getter = "get_stair_props"
|
||||
type_literal = tool.Model.StairType
|
||||
type_attr = "stair_type"
|
||||
skip_element_check = True
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._pick_type(context)
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
|
||||
@@ -482,47 +460,20 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
# === 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.
|
||||
# === 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
|
||||
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"
|
||||
pick_type_operator = "bim.pick_stair_type"
|
||||
cycle_type_operator = "bim.cycle_stair_type"
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for stair icon positioning.
|
||||
@@ -627,91 +578,83 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
]
|
||||
|
||||
# Metadata-driven dispatch for props and preferences
|
||||
props_getter = tool.Model.get_stair_props
|
||||
props_getter = "get_stair_props"
|
||||
gizmo_pref_name = "stair"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_stair(element)
|
||||
return tool.Blender.Modifier.is_stair(element)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""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",
|
||||
"""Create stair-specific icon gizmos (lock, plus, minus)."""
|
||||
self.lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
self.COLOR_BLUE,
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.total_length_lock",
|
||||
property_name="total_length_lock",
|
||||
)
|
||||
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")
|
||||
self.tread_lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
(1.0, 1.0, 1.0),
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.custom_tread_lock",
|
||||
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
|
||||
)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
) -> None:
|
||||
"""Update stair-specific lock and tread count gizmos. Lock positioning is
|
||||
handled per-frame in the dimension-positioning hook."""
|
||||
self.update_lock_gizmo(props)
|
||||
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
|
||||
"""Update stair-specific lock and tread count gizmos."""
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
self.update_lock_gizmo(mw, props, billboard_rot)
|
||||
self.update_tread_lock_gizmo(props)
|
||||
self.update_tread_count_gizmos(props)
|
||||
|
||||
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""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_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
|
||||
"""Update lock gizmo visibility, color, and position."""
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
|
||||
return # Hidden, skip positioning
|
||||
|
||||
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
|
||||
|
||||
total_run = props.get_total_run()
|
||||
local_transform = (
|
||||
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
|
||||
@ billboard_rot
|
||||
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
|
||||
)
|
||||
self.lock_gizmo.matrix_basis = mw @ local_transform
|
||||
|
||||
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""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"):
|
||||
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "tread_lock_gizmo"):
|
||||
return
|
||||
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
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
|
||||
|
||||
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of the +/- tread count gizmos and the ``xN``
|
||||
label. Positioning is handled in ``_update_editing_icon_positions``."""
|
||||
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
|
||||
return
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
|
||||
# Minus has additional condition: number_of_treads > 1
|
||||
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)
|
||||
self.update_gizmo_visibility(
|
||||
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
|
||||
)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
|
||||
) -> None:
|
||||
"""Update dimension gizmo positions based on camera view direction."""
|
||||
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
total_run = props.get_total_run()
|
||||
riser_height = props.get_riser_height()
|
||||
|
||||
@@ -782,12 +725,10 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
billboard_rot: Matrix,
|
||||
total_run: float,
|
||||
) -> None:
|
||||
"""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."""
|
||||
"""Update lock gizmo position based on Y view direction."""
|
||||
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"total_length_lock_open_gizmo",
|
||||
"total_length_lock_closed_gizmo",
|
||||
self.set_icon_gizmo_position(
|
||||
"lock_gizmo",
|
||||
mw,
|
||||
total_run + self.ICON_Z_OFFSET,
|
||||
y_pos,
|
||||
@@ -799,47 +740,30 @@ 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:
|
||||
"""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."""
|
||||
"""Update editing icon positions, flipping Y based on viewing angle."""
|
||||
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_pair_position(
|
||||
"tread_lock_open_gizmo",
|
||||
"tread_lock_closed_gizmo",
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_lock_gizmo",
|
||||
mw,
|
||||
slot_x["tread_lock"],
|
||||
self.ICON_TREAD_LOCK_X,
|
||||
y_pos,
|
||||
icon_z - self.EDITING_ICON_SCALE / 2,
|
||||
billboard_rot,
|
||||
scale=self.EDITING_ICON_SCALE,
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"minus_gizmo", mw, self.ICON_MINUS_X, 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,
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
from bpy.types import Panel
|
||||
|
||||
import bonsai.bim
|
||||
@@ -238,11 +237,11 @@ class BIM_PT_array(bpy.types.Panel):
|
||||
|
||||
for i, array in enumerate(ArrayData.data["parameters"]["data_dict"]):
|
||||
box = self.layout.box()
|
||||
if props.editing_item_index == i:
|
||||
if props.is_editing == i:
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "count", icon="MOD_ARRAY")
|
||||
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
|
||||
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
|
||||
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
|
||||
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "method")
|
||||
row = box.row(align=True)
|
||||
@@ -304,8 +303,6 @@ class BIM_PT_stair(bpy.types.Panel):
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Stair parameters", icon="IPO_CONSTANT")
|
||||
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
if props.is_editing:
|
||||
calculated_params = tool.Model.get_active_stair_calculated_params()
|
||||
row = self.layout.row(align=True)
|
||||
@@ -325,61 +322,22 @@ class BIM_PT_stair(bpy.types.Panel):
|
||||
row.label(text=f"{prop_name}:")
|
||||
row = self.layout.row(align=True)
|
||||
for prop_value_item in prop_value:
|
||||
if isinstance(prop_value_item, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value_item * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value_item))
|
||||
row.label(text=str(prop_value_item))
|
||||
else:
|
||||
row.label(text=prop_name)
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value))
|
||||
row.label(text=str(prop_value))
|
||||
|
||||
# calculated properties
|
||||
for prop_name, prop_value in calculated_params.items():
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text=prop_name)
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value))
|
||||
row.label(text=str(prop_value))
|
||||
else:
|
||||
row = self.layout.row()
|
||||
row.label(text="No Stair Found")
|
||||
row.operator("bim.add_stair", icon="ADD", text="")
|
||||
|
||||
|
||||
class BIM_PT_wall(bpy.types.Panel):
|
||||
bl_label = "Wall"
|
||||
bl_idname = "BIM_PT_wall"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
bl_parent_id = "BIM_PT_tab_parametric_geometry"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return bool(element) and tool.Parametric.is_wall(element)
|
||||
|
||||
def draw(self, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return
|
||||
props = tool.Model.get_wall_props(obj)
|
||||
row = self.layout.row(align=True)
|
||||
if props.is_editing:
|
||||
row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
|
||||
row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
|
||||
else:
|
||||
row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
|
||||
|
||||
|
||||
class BIM_PT_sverchok(bpy.types.Panel):
|
||||
bl_label = "Sverchok"
|
||||
bl_idname = "BIM_PT_sverchok"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,279 +0,0 @@
|
||||
# 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,8 +39,6 @@ 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.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -484,53 +482,90 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class _WindowEditMixin(FeatureModifierEditMixin):
|
||||
"""Type-specific hooks for window parametric-edit operators. Single-object
|
||||
by design (window edits target the active object only)."""
|
||||
|
||||
pset_name = "BBIM_Window"
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_window_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_window_modifier_representation(context)
|
||||
|
||||
|
||||
class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_window"
|
||||
bl_label = "Cancel Editing Window"
|
||||
bl_description = "Cancel editing and revert window parameters to their previous values"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cancel_targets(context)
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
props = tool.Model.get_window_props(obj)
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=body,
|
||||
)
|
||||
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_window"
|
||||
bl_label = "Finish Editing Window"
|
||||
bl_description = "Apply changes and finish editing window parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._finish_targets(context)
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
props = tool.Model.get_window_props(obj)
|
||||
|
||||
window_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
|
||||
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
|
||||
|
||||
window_data["lining_properties"] = lining_props
|
||||
window_data["panel_properties"] = panel_props
|
||||
|
||||
props.is_editing = False
|
||||
|
||||
update_window_modifier_representation(context)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
tool.Model.mark_thumbnail_for_update(element_type)
|
||||
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_Window")
|
||||
window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data})
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_window"
|
||||
bl_label = "Enable Editing Window"
|
||||
bl_description = "Enter edit mode to modify window parameters interactively"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._enable_targets(context)
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
props = tool.Model.get_window_props(obj)
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
data.update(tool.Model.get_constituents_props_data(element))
|
||||
|
||||
# required since we could load pset from .ifc and BIMWindowProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -552,20 +587,20 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a window type from a popup menu."""
|
||||
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available window types. Shift+click to cycle in reverse."""
|
||||
|
||||
bl_idname = "bim.pick_window_type"
|
||||
bl_label = "Pick Window Type"
|
||||
bl_idname = "bim.cycle_window_type"
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_window
|
||||
props_getter = tool.Model.get_window_props
|
||||
element_checker = "is_window"
|
||||
props_getter = "get_window_props"
|
||||
type_literal = tool.Model.WindowType
|
||||
type_attr = "window_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._pick_type(context)
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
|
||||
@@ -603,7 +638,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"
|
||||
pick_type_operator = "bim.pick_window_type"
|
||||
cycle_type_operator = "bim.cycle_window_type"
|
||||
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
dimension_gizmo_props = [
|
||||
@@ -744,15 +779,14 @@ 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
|
||||
props_getter = "get_window_props"
|
||||
gizmo_pref_name = "window"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_window(element)
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for window icon positioning.
|
||||
|
||||
@@ -841,7 +841,7 @@ class EditObjectUI:
|
||||
row = cls.layout.row(align=True)
|
||||
row.separator()
|
||||
row.label(text="Operations") if ui_context != "TOOL_HEADER" else row
|
||||
cls.draw_regen_operations(row, ui_context)
|
||||
cls.draw_regen_operations(row)
|
||||
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
@@ -962,20 +962,20 @@ class EditObjectUI:
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
def draw_regen_operations(cls, row, ui_context):
|
||||
# ``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)
|
||||
def draw_regen_operations(cls, row):
|
||||
custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id
|
||||
|
||||
if AuthoringData.data["is_regenable_element"]:
|
||||
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
|
||||
description = "Recalculate Element Geometry\nHotkey: S G"
|
||||
op.hotkey = "S_G"
|
||||
op.description = description.strip()
|
||||
|
||||
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
|
||||
)
|
||||
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
|
||||
description = f"{bpy.ops.bim.regenerate_distribution_element.__doc__}\n\nHotkey: S G"
|
||||
op.hotkey = "S_G"
|
||||
op.description = description.strip()
|
||||
|
||||
@classmethod
|
||||
def draw_void(cls, context, row):
|
||||
@@ -1300,15 +1300,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.generate_space()
|
||||
return
|
||||
if self.active_material_usage == "LAYER2":
|
||||
if element and tool.Model.has_underside_connection(element):
|
||||
bpy.ops.bim.regenerate_wall_to_underside()
|
||||
else:
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
elif self.active_material_usage == "LAYER3":
|
||||
bpy.ops.bim.recalculate_slab()
|
||||
wall_objs = tool.Model.get_connected_wall_objs(element)
|
||||
if wall_objs:
|
||||
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
|
||||
elif tool.System.get_ports(element):
|
||||
bpy.ops.bim.regenerate_distribution_element()
|
||||
elif self.active_material_usage == "PROFILE":
|
||||
@@ -1321,7 +1315,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):
|
||||
@@ -1448,7 +1442,10 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.enable_editing_extrusion_axis()
|
||||
|
||||
def hotkey_A_O(self):
|
||||
bpy.ops.bim.toggle_host_openings()
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
|
||||
def hotkey_C_E(self):
|
||||
if not bpy.context.selected_objects:
|
||||
|
||||
@@ -20,6 +20,7 @@ import blf
|
||||
import bpy
|
||||
import gpu
|
||||
import ifcopenshell.util.element
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras import view3d_utils
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
@@ -27,6 +28,12 @@ from mathutils import Vector
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
def create_bounding_box(objs):
|
||||
# Initialize the bounding box coordinates
|
||||
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
|
||||
@@ -72,8 +79,26 @@ def create_bounding_box(objs):
|
||||
return indices, edges
|
||||
|
||||
|
||||
class NestDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_nest"
|
||||
class NestDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
@@ -129,6 +154,14 @@ class NestDecorator(tool.Blender.ViewportDecorator):
|
||||
shader.uniform_float("u_Scale", 25)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_nest(self, context: bpy.types.Context) -> None:
|
||||
props = tool.Nest.get_nest_props()
|
||||
if props.in_nest_mode:
|
||||
@@ -193,11 +226,35 @@ class NestDecorator(tool.Blender.ViewportDecorator):
|
||||
self.draw_custom_batch(line, decorator_color_unselected)
|
||||
|
||||
|
||||
class NestModeDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_nest_name", "POST_PIXEL"),
|
||||
("draw_nest_empty", "POST_VIEW"),
|
||||
)
|
||||
class NestModeDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_name, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_empty, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_nest_name(self, context):
|
||||
if context.mode == "EDIT_MESH":
|
||||
|
||||
@@ -21,7 +21,6 @@ import bpy
|
||||
from . import operator, prop, ui
|
||||
|
||||
classes = (
|
||||
operator.AddIfcPatchPreset,
|
||||
operator.ExecuteIfcPatch,
|
||||
operator.ExtractSelectedElements,
|
||||
operator.RunMigratePatch,
|
||||
@@ -29,7 +28,6 @@ classes = (
|
||||
operator.SelectIfcPatchOutput,
|
||||
operator.UpdateIfcPatchArguments,
|
||||
prop.BIMPatchProperties,
|
||||
ui.BIM_MT_ifc_patch_presets,
|
||||
ui.BIM_PT_patch,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ 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
|
||||
@@ -78,27 +77,6 @@ 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
|
||||
@@ -246,38 +224,3 @@ 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,15 +71,6 @@ 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,20 +29,6 @@ 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"
|
||||
@@ -80,11 +66,6 @@ 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,12 +28,6 @@ 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,
|
||||
@@ -45,6 +39,7 @@ classes = (
|
||||
operator.DisableEditingHeader,
|
||||
operator.DisableEditingLink,
|
||||
operator.EditHeader,
|
||||
operator.EditLink,
|
||||
operator.EditProjectLibrary,
|
||||
operator.EnableCulling,
|
||||
operator.EnableEditingHeader,
|
||||
@@ -66,7 +61,6 @@ classes = (
|
||||
operator.QueryLinkedElement,
|
||||
operator.RefreshClippingPlanes,
|
||||
operator.RefreshLibrary,
|
||||
operator.ReloadAllLinks,
|
||||
operator.ReloadLink,
|
||||
operator.RemoveProjectLibrary,
|
||||
operator.RevertProject,
|
||||
@@ -74,7 +68,6 @@ classes = (
|
||||
operator.SaveLibraryFile,
|
||||
operator.SelectLibraryFile,
|
||||
operator.SelectLinkedModelElement,
|
||||
operator.SelectLinkFilepath,
|
||||
operator.SelectLinkHandle,
|
||||
operator.ToggleFilterCategories,
|
||||
operator.ToggleLinkSelectability,
|
||||
@@ -89,8 +82,6 @@ classes = (
|
||||
prop.FilterCategory,
|
||||
prop.Link,
|
||||
prop.EditedObj,
|
||||
prop.PendingArrayRepair,
|
||||
prop.PendingOpeningRecut,
|
||||
prop.BIMProjectProperties,
|
||||
prop.MeasureToolSettings,
|
||||
ui.BIM_MT_new_project,
|
||||
@@ -110,45 +101,12 @@ classes = (
|
||||
addon_keymaps = []
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _autosave_link_transforms(scene, depsgraph):
|
||||
"""Persist link transformations whenever an editing link's handle is moved.
|
||||
|
||||
Deliberate exemption from the transaction rule in
|
||||
docs/guides/development/undo_system.rst: a handler cannot run inside
|
||||
execute_ifc_operator, so this IFC write is not undo-tracked. It stays
|
||||
consistent anyway because undoing the move fires another depsgraph
|
||||
update, which re-saves the reverted matrix.
|
||||
"""
|
||||
import bonsai.tool as tool
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
if not props.links:
|
||||
return
|
||||
handles = None
|
||||
for update in depsgraph.updates:
|
||||
if not update.is_updated_transform or not isinstance(update.id, bpy.types.Object):
|
||||
continue
|
||||
if handles is None:
|
||||
# Built lazily so ticks without transform updates stay cheap.
|
||||
handles = {}
|
||||
for link in props.links:
|
||||
if link.is_loaded and link.is_editing and (handle := tool.Project.get_link_empty_handle(link)):
|
||||
handles[handle] = link
|
||||
if not handles:
|
||||
return
|
||||
if link := handles.get(update.id.original):
|
||||
tool.Project.save_link_transformation(link)
|
||||
|
||||
|
||||
def register():
|
||||
if not bpy.app.background:
|
||||
bpy.utils.register_tool(workspace.ExploreTool, after={"builtin.transform"}, separator=True, group=False)
|
||||
bpy.types.Scene.BIMProjectProperties = bpy.props.PointerProperty(type=prop.BIMProjectProperties)
|
||||
bpy.types.Scene.MeasureToolSettings = bpy.props.PointerProperty(type=prop.MeasureToolSettings)
|
||||
bpy.app.handlers.load_post.append(decorator.toggle_decorations_on_load)
|
||||
if _autosave_link_transforms not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_autosave_link_transforms)
|
||||
bpy.types.TOPBAR_MT_file_import.append(ui.file_import_menu)
|
||||
bpy.types.TOPBAR_MT_file.prepend(ui.file_menu)
|
||||
bpy.types.TOPBAR_MT_file_context_menu.prepend(ui.file_menu)
|
||||
@@ -173,8 +131,6 @@ def unregister():
|
||||
del bpy.types.Scene.BIMProjectProperties
|
||||
del bpy.types.Scene.MeasureToolSettings
|
||||
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
|
||||
if _autosave_link_transforms in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_autosave_link_transforms)
|
||||
bpy.types.TOPBAR_MT_file.remove(ui.file_menu)
|
||||
bpy.types.TOPBAR_MT_file_context_menu.remove(ui.file_menu)
|
||||
|
||||
|
||||
@@ -162,8 +162,8 @@ class ProjectLibraryData:
|
||||
library_file = IfcStore.library_file
|
||||
if library_file is None or library_file.schema == "IFC2X3":
|
||||
return results
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
results.append((str(project.id()), f"IfcProject {project.Name or 'Unnamed'}", project.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
|
||||
|
||||
@@ -42,6 +42,12 @@ def toggle_decorations_on_load(*args):
|
||||
# as queried object is linked from separate .blend file.
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
class ProjectDecorator:
|
||||
installed = None
|
||||
|
||||
@@ -74,6 +80,11 @@ class ProjectDecorator:
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
special_elements_color = self.addon_prefs.decorator_color_special
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
gpu.state.point_size_set(6)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
@@ -99,10 +110,7 @@ class ProjectDecorator:
|
||||
|
||||
if geom.selected_edges:
|
||||
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
|
||||
if geom.selected_tris:
|
||||
self.draw_batch(
|
||||
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris
|
||||
)
|
||||
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), geom.selected_tris)
|
||||
|
||||
|
||||
class ClippingPlaneDecorator:
|
||||
@@ -137,6 +145,11 @@ class ClippingPlaneDecorator:
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
special_elements_color = self.addon_prefs.decorator_color_special
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
gpu.state.point_size_set(6)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
@@ -197,21 +210,37 @@ class ClippingPlaneDecorator:
|
||||
|
||||
if unselected_edges:
|
||||
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
|
||||
self.draw_batch(
|
||||
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
|
||||
)
|
||||
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
|
||||
if selected_edges:
|
||||
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
|
||||
self.draw_batch(
|
||||
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
|
||||
)
|
||||
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
|
||||
|
||||
|
||||
class MeasureDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_measurements_text", "POST_PIXEL"),
|
||||
("draw_measurements_poly", "POST_VIEW"),
|
||||
)
|
||||
class MeasureDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_measurements_text, (context,), "WINDOW", "POST_PIXEL")
|
||||
)
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_measurements_poly, (context,), "WINDOW", "POST_VIEW")
|
||||
)
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_measurements_text(self, context):
|
||||
PolylineDecorator().select_and_draw_measurements_text(context)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -98,8 +98,7 @@ 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)
|
||||
if parent_library := tool.Project.get_parent_library(project_library):
|
||||
self.parent_library = str(parent_library.id())
|
||||
self.parent_library = str(tool.Project.get_parent_library(project_library).id())
|
||||
ProjectLibraryData.load() # Show edit icon in enum.
|
||||
return
|
||||
|
||||
@@ -260,24 +259,6 @@ class Link(PropertyGroup):
|
||||
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
|
||||
default=0,
|
||||
)
|
||||
query: StringProperty(
|
||||
name="Include",
|
||||
description="Selector query for the elements to load from the linked model",
|
||||
default="",
|
||||
)
|
||||
exclude: StringProperty(
|
||||
name="Exclude",
|
||||
description="Selector query whose matches are excluded when loading the linked model",
|
||||
default="",
|
||||
)
|
||||
display_name: StringProperty(
|
||||
name="Name",
|
||||
description=(
|
||||
"Optional display name to tell links apart (e.g. when the same file "
|
||||
"is linked several times). Shows the file path when empty"
|
||||
),
|
||||
default="",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: str
|
||||
@@ -293,9 +274,6 @@ class Link(PropertyGroup):
|
||||
include_in_drawings: bool
|
||||
empty_handle: Union[bpy.types.Object, None]
|
||||
ifc_definition_id: int
|
||||
query: str
|
||||
exclude: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class EditedObj(PropertyGroup):
|
||||
@@ -317,28 +295,6 @@ 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)
|
||||
@@ -404,8 +360,6 @@ 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,
|
||||
@@ -571,8 +525,6 @@ 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"]
|
||||
|
||||
@@ -492,13 +492,17 @@ class BIM_PT_links(Panel):
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.operator("bim.link_ifc")
|
||||
row.operator("bim.reload_all_links", text="", icon="FILE_REFRESH")
|
||||
if self.props.links:
|
||||
if self.props.active_link:
|
||||
row = self.layout.row(align=True)
|
||||
row.alignment = "RIGHT"
|
||||
index = self.props.active_link_index
|
||||
if self.props.active_link.is_loaded:
|
||||
if self.props.active_link.is_editing:
|
||||
row.operator("bim.edit_link", text="", icon="CHECKMARK")
|
||||
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
|
||||
else:
|
||||
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
|
||||
row.operator("bim.select_linked_model_element", icon="VIEWZOOM", text="")
|
||||
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
|
||||
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
|
||||
@@ -639,12 +643,7 @@ class BIM_UL_links(UIList):
|
||||
if item.has_transformation:
|
||||
row.label(text="", icon="OBJECT_ORIGIN")
|
||||
|
||||
# Double-click to rename; shows the file path while unset.
|
||||
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
|
||||
if item.is_editing:
|
||||
row.operator("bim.disable_editing_link", text="", icon="UNLOCKED", emboss=False).link_index = index
|
||||
else:
|
||||
row.operator("bim.enable_editing_link", text="", icon="LOCKED", emboss=False).link_index = index
|
||||
row.label(text=item.filepath)
|
||||
icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
|
||||
row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
|
||||
icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
|
||||
@@ -656,7 +655,7 @@ class BIM_UL_links(UIList):
|
||||
op.link_index = index
|
||||
op.mode = "VISIBLE"
|
||||
else:
|
||||
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
|
||||
row.label(text=item.filepath)
|
||||
|
||||
|
||||
class BIM_PT_purge(Panel):
|
||||
|
||||
@@ -88,6 +88,44 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
props.active_pset_type = "-"
|
||||
|
||||
|
||||
def _regenerate_parametric_dimension(file, annotation):
|
||||
"""Regenerate a single parametric dimension annotation after a pset edit."""
|
||||
try:
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as _tool
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
return
|
||||
|
||||
anchors = json.loads(pset_data["Anchors"])
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = _tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, annotation, placement_override=placement_override
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_pset"
|
||||
bl_label = "Edit Pset"
|
||||
@@ -150,7 +188,12 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
if tool.Cost.has_schedules():
|
||||
tool.Cost.update_cost_items(pset=pset)
|
||||
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
|
||||
|
||||
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
|
||||
if is_bbim_dimension:
|
||||
_regenerate_parametric_dimension(self.file, element)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
|
||||
|
||||
@@ -18,17 +18,43 @@
|
||||
|
||||
import blf
|
||||
import gpu
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class GridDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw", "POST_VIEW"),
|
||||
)
|
||||
class GridDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_text(self, context):
|
||||
if not tool.Blender.is_addon_enabled():
|
||||
|
||||
@@ -302,15 +302,6 @@ class SelectSimilarContainer(bpy.types.Operator):
|
||||
is_recursive=self.is_recursive,
|
||||
)
|
||||
self.is_recursive = True # <-- forcibly reset
|
||||
|
||||
element = tool.Ifc.get_entity(context.active_object)
|
||||
if element:
|
||||
container = tool.Spatial.get_container(element)
|
||||
if container:
|
||||
result = f'location="{container.Name}"'
|
||||
bpy.context.window_manager.clipboard = result
|
||||
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
@@ -31,17 +31,11 @@ import bonsai.tool as tool
|
||||
from bonsai.bim.module.structural.load_decoration_data import ShaderInfo
|
||||
|
||||
|
||||
class LoadsDecorator(tool.Blender.ViewportDecorator):
|
||||
class LoadsDecorator:
|
||||
"""Decorator to show structural loads in 3D"""
|
||||
|
||||
# draw_methods exists to satisfy ViewportDecorator.__init_subclass__'s
|
||||
# method-existence check; the override install below is what actually
|
||||
# registers handlers (the POST_VIEW binding passes no context arg, which
|
||||
# the base's generic install cannot express).
|
||||
draw_methods = (
|
||||
("draw_load_values", "POST_PIXEL"),
|
||||
("__call__", "POST_VIEW"),
|
||||
)
|
||||
is_installed = False
|
||||
handlers = []
|
||||
decoration_data = None
|
||||
text_info = []
|
||||
shader_info = []
|
||||
@@ -60,6 +54,15 @@ class LoadsDecorator(tool.Blender.ViewportDecorator):
|
||||
cls.update()
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls) -> None:
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
@classmethod
|
||||
def update(cls) -> None:
|
||||
cls.decoration_data.update()
|
||||
|
||||
@@ -744,7 +744,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator):
|
||||
if self.ifc_class == "IfcSurfaceStyleLighting":
|
||||
|
||||
def callback(attribute_name: str, _: object, data: dict[str, Any]) -> None:
|
||||
assert attributes is not None
|
||||
assert attributes
|
||||
color = attributes.add()
|
||||
assert isinstance(color, ColourRgb)
|
||||
color.name = attribute_name
|
||||
@@ -782,40 +782,34 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.props = tool.Style.get_style_props()
|
||||
self.style = tool.Ifc.get().by_id(self.props.is_editing_style)
|
||||
prev_update_graph = self.props.update_graph
|
||||
self.props["update_graph"] = False
|
||||
|
||||
try:
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
if result:
|
||||
return result
|
||||
|
||||
if result:
|
||||
return result
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
finally:
|
||||
self.props["update_graph"] = prev_update_graph
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
|
||||
def edit_existing_style(self) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
@@ -1237,5 +1231,4 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
surface_style = tool.Style.get_style_elements(style)[props.is_editing_class]
|
||||
ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style)
|
||||
core.disable_editing_style(tool.Style)
|
||||
core.load_styles(tool.Style, style_type=props.style_type)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -185,13 +185,6 @@ class ColourRgb(PropertyGroup):
|
||||
# to fit blender.bim.helper.draw_attribute
|
||||
is_optional = False
|
||||
special_type = ""
|
||||
data_type = ""
|
||||
ifc_class = ""
|
||||
use_explorer_ui = False
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.name
|
||||
|
||||
def get_value_name(self, *args, **kwargs):
|
||||
return "color_value"
|
||||
|
||||
@@ -176,30 +176,8 @@ class BIM_PT_styles(Panel):
|
||||
row.prop(self.props, "reflectance_method")
|
||||
|
||||
if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
|
||||
self.layout.label(
|
||||
text=f"{self.props.reflectance_method} will be skipped: only PHYSICAL / NOTDEFINED / FLAT are supported",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method in ("PHYSICAL", "NOTDEFINED"):
|
||||
if self.props.specular_colour_class == "IfcColourRgb":
|
||||
self.layout.label(
|
||||
text="Metallic color is IFC-only in PHYSICAL/NOTDEFINED and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method == "FLAT":
|
||||
if self.props.diffuse_colour_class == "IfcNormalisedRatioMeasure":
|
||||
self.layout.label(
|
||||
text="Emissive ratio is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Specular value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Highlight value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(text="Supported reflectance methods are:")
|
||||
self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT")
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse")
|
||||
@@ -254,8 +232,6 @@ class BIM_PT_styles(Panel):
|
||||
row.operator("bim.add_surface_texture", text="", icon="ADD")
|
||||
if textures:
|
||||
self.layout.prop(self.props, "uv_mode")
|
||||
if self.props.uv_mode in ("Generated", "Camera"):
|
||||
self.layout.label(text="Not available in SOLID Mode", icon="INFO")
|
||||
|
||||
for i, texture in enumerate(textures):
|
||||
split = self.layout.split(factor=0.30, align=True)
|
||||
@@ -268,22 +244,6 @@ class BIM_PT_styles(Panel):
|
||||
op_clear = row.operator("bim.remove_texture_map", text="", icon="X")
|
||||
op_path.texture_map_index = op_clear.texture_map_index = i
|
||||
|
||||
reflectance = self.props.reflectance_method
|
||||
mode = texture.mode
|
||||
if reflectance == "FLAT":
|
||||
if mode != "EMISSIVE":
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: only EMISSIVE is supported for Render Reflectance FLAT",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif reflectance in ("PHYSICAL", "NOTDEFINED"):
|
||||
_SUPPORTED = {"DIFFUSE", "NORMAL", "METALLICROUGHNESS", "EMISSIVE", "OCCLUSION"}
|
||||
if mode not in _SUPPORTED:
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: not supported for Render Reflectance PHYSICAL/NOTDEFINED",
|
||||
icon="ERROR",
|
||||
)
|
||||
|
||||
def draw_externally_defined_surface_style(self):
|
||||
row = self.layout.row()
|
||||
op = row.operator("bim.browse_external_style", icon="APPEND_BLEND", text="Append From Blend File")
|
||||
@@ -292,17 +252,10 @@ class BIM_PT_styles(Panel):
|
||||
bonsai.bim.helper.draw_attributes(self.props.external_style_attributes, self.layout, enable_search=True)
|
||||
|
||||
def draw_refraction_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Refraction values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.refraction_style_attributes, self.layout, enable_search=True)
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
def draw_lighting_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Lighting values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.lighting_style_colours, self.layout)
|
||||
|
||||
def draw_edit_ui(self, edit_label: str):
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
#
|
||||
# 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
|
||||
@@ -31,6 +30,12 @@ ERROR_ELEMENTS_COLOR = (1, 0.2, 0.322, 1) # RED
|
||||
UNSPECIAL_ELEMENT_COLOR = (0.2, 0.2, 0.2, 1) # GREY
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
@persistent
|
||||
def toggle_decorations_on_load(*args):
|
||||
props = tool.System.get_system_props()
|
||||
@@ -73,9 +78,15 @@ class SystemDecorator:
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
faces_color = tool.Blender.transparent_color(self.addon_prefs.decorator_color_special)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
|
||||
"""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]
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
|
||||
|
||||
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -122,15 +133,13 @@ class SystemDecorator:
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
self.shader.bind()
|
||||
|
||||
self.draw_batch(
|
||||
"LINES", all_vertices, tool.Blender.transparent_color(unselected_elements_color), unselected_edges
|
||||
)
|
||||
self.draw_batch("LINES", all_vertices, transparent_color(unselected_elements_color), unselected_edges)
|
||||
self.draw_batch("LINES", all_vertices, selected_elements_color, selected_edges)
|
||||
self.draw_batch("LINES", all_vertices, UNSPECIAL_ELEMENT_COLOR, arc_edges)
|
||||
self.draw_batch("LINES", all_vertices, special_elements_color, preview_edges)
|
||||
self.draw_batch("LINES", all_vertices, special_elements_color, roof_angle_edges)
|
||||
|
||||
self.draw_batch("POINTS", unselected_vertices, tool.Blender.transparent_color(unselected_elements_color, 0.5))
|
||||
self.draw_batch("POINTS", unselected_vertices, transparent_color(unselected_elements_color, 0.5))
|
||||
self.draw_batch("POINTS", error_vertices, ERROR_ELEMENTS_COLOR)
|
||||
self.draw_batch("POINTS", special_vertices, special_elements_color)
|
||||
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
|
||||
|
||||
@@ -317,23 +317,13 @@ 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_guid: bpy.props.StringProperty(name="Object 1 GlobalId")
|
||||
obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId")
|
||||
obj1_name: bpy.props.StringProperty(name="Object 1")
|
||||
obj2_name: bpy.props.StringProperty(name="Object 2")
|
||||
|
||||
def _execute(self, context):
|
||||
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"}
|
||||
if self.obj1_name and self.obj2_name:
|
||||
obj1 = bpy.data.objects.get(self.obj1_name)
|
||||
obj2 = bpy.data.objects.get(self.obj2_name)
|
||||
else:
|
||||
if not context.selected_objects or len(context.selected_objects) != 2:
|
||||
self.report({"ERROR"}, "Need to select 2 objects.")
|
||||
|
||||
@@ -65,28 +65,10 @@ 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
|
||||
@@ -394,22 +376,12 @@ 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 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 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 obj in context.selectable_objects:
|
||||
tool.Blender.select_and_activate_single_object(context, new_obj)
|
||||
|
||||
@@ -151,8 +151,7 @@ class BIM_PT_type_attributes(Panel):
|
||||
row = layout.row(align=True)
|
||||
row.label(text=attribute["name"])
|
||||
value = get_display_value(attribute["value"])
|
||||
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
|
||||
op.key = "type." + attribute["name"]
|
||||
row.label(text=value)
|
||||
|
||||
|
||||
def add_object_button(self, context):
|
||||
|
||||
@@ -26,7 +26,7 @@ import bonsai.bim.handler
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator, is_filling_supported
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
|
||||
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -34,21 +34,11 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Apply Opening"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = (
|
||||
"Cuts openings in a wall, slab, or roof using selected shape objects — "
|
||||
"doors, windows, existing openings, or plain (non-IFC) meshes. "
|
||||
"Selection order doesn't matter.\n\n"
|
||||
"Doors and windows also fill the opening. Other IFC classes are currently "
|
||||
"unsupported by the opening generator and get skipped with a warning.\n\n"
|
||||
"Shift+click: keep each opening at its shape object's current position "
|
||||
"instead of snapping to the wall."
|
||||
"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."
|
||||
)
|
||||
|
||||
# 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:
|
||||
@@ -56,17 +46,7 @@ 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):
|
||||
# Multi-opening drops on the same host fan out N update_representation
|
||||
# writes + N switch_representation recuts without batching. Coalesce.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
return self._add_openings(context)
|
||||
|
||||
def _add_openings(self, context):
|
||||
selected_objects = context.selected_objects
|
||||
target_object = selected_objects[0]
|
||||
|
||||
@@ -86,20 +66,9 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
self.report({"INFO"}, "You can't add an opening to another opening.")
|
||||
continue
|
||||
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
|
||||
if is_filling_supported(element1): # Add a fill to an element.
|
||||
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
|
||||
obj1, obj2 = obj2, obj1
|
||||
elif not is_filling_supported(element2):
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Cannot apply {element2.is_a()} as an opening — Bonsai currently supports only IfcDoor and IfcWindow as parametric fillings.",
|
||||
)
|
||||
continue
|
||||
FilledOpeningGenerator().generate(
|
||||
obj2,
|
||||
obj1,
|
||||
target=obj2.matrix_world.translation,
|
||||
preserve_placement=self.preserve_placement,
|
||||
)
|
||||
FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation)
|
||||
continue
|
||||
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.
|
||||
@@ -179,7 +148,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
voided_obj.scale = (1.0, 1.0, 1.0)
|
||||
tool.Ifc.finish_edit(voided_obj)
|
||||
else:
|
||||
tool.Geometry.update_host_representation(voided_obj)
|
||||
bpy.ops.bim.update_representation(obj=voided_obj.name)
|
||||
|
||||
if tool.Ifc.is_moved(voided_obj):
|
||||
bonsai.core.geometry.edit_object_placement(
|
||||
@@ -188,7 +157,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
representation = tool.Geometry.get_active_representation(voided_obj)
|
||||
assert representation
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=voided_obj,
|
||||
representation=representation,
|
||||
)
|
||||
tool.Geometry.lock_scale(voided_obj)
|
||||
|
||||
if not has_visible_openings:
|
||||
@@ -226,7 +200,12 @@ class RemoveOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if building_obj and building_obj.data:
|
||||
representation = tool.Geometry.get_active_representation(building_obj)
|
||||
assert representation
|
||||
tool.Geometry.recut_host(building_obj, representation)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=building_obj,
|
||||
representation=representation,
|
||||
)
|
||||
tool.Geometry.unlock_scale_object_with_openings(obj)
|
||||
tool.Geometry.clear_cache(element)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1,656 +0,0 @@
|
||||
# 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 operator mixins for parametric-edit operators.
|
||||
|
||||
Edit-lifecycle mixins (Enable / Finish / Cancel):
|
||||
`FeatureModifierEditMixin` — door, window (BBIM_<Type> pset; nested
|
||||
lining/panel properties; Finish + Cancel route through
|
||||
``ifcopenshell.api.feature``).
|
||||
`PathPreservingEditMixin` — railing, roof (path_data preserved across
|
||||
edit; only general kwargs are user-editable).
|
||||
|
||||
Pattern selection (which approach a new feature should adopt):
|
||||
Every parametric edit lifecycle commits to one of three patterns. Pick by
|
||||
answering "does the feature share the Enable→Finish→Cancel shape that
|
||||
one of the existing mixins already encodes?":
|
||||
|
||||
A. Inherit one of the shared mixins below and route through
|
||||
`tool.Parametric.build_edit_lifecycle`:
|
||||
|
||||
- `FeatureModifierEditMixin` when the feature stores its pset as
|
||||
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
|
||||
and Finish must call a per-type `update_<type>_modifier_representation`.
|
||||
|
||||
- `PathPreservingEditMixin` when the feature's pset carries a
|
||||
`path_data` field that survives general-kwarg edits untouched, with
|
||||
a separate Enable/Finish/Cancel lifecycle for path editing itself.
|
||||
|
||||
B. Write a per-feature mixin that subclasses `ParametricEditMixinBase`
|
||||
and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`,
|
||||
then route through `build_edit_lifecycle`. Pick this when the
|
||||
feature's pset roundtrip or representation handling diverges from the
|
||||
shared mixins but the Enable→Finish→Cancel shape still fits.
|
||||
|
||||
C. Declare standalone Enable/Finish/Cancel Operator subclasses (no
|
||||
factory) when the feature's parameter-change logic is sufficiently
|
||||
unique that even a per-feature mixin would force optional hooks or
|
||||
dead branches. Such operators MUST call the matrix_world drift
|
||||
helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish,
|
||||
`tool.Geometry.restore_or_rebaseline_placement` on Cancel) — the
|
||||
drift contract is enforced uniformly regardless of which pattern the
|
||||
operators 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.
|
||||
|
||||
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
|
||||
The lightweight parametric registry consumed at addon-enable time must stay
|
||||
free of such imports and lives separately in ``tool/parametric.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar, get_args
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.element
|
||||
from bpy.app.handlers import persistent
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class ParametricEditMixinBase:
|
||||
"""Common scaffolding for parametric edit-lifecycle mixins.
|
||||
|
||||
Each per-type subclass provides four hooks:
|
||||
|
||||
``pset_name``: BBIM_<Type> pset identifier
|
||||
``_is_element_type(element)``: IFC element predicate
|
||||
``_get_props(obj)``: PropertyGroup accessor
|
||||
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
|
||||
|
||||
Drift handling is built in: pre-edit matrix_world drift commits to IFC on
|
||||
Enable, in-edit drag commits on Finish, and Cancel restores the committed
|
||||
IFC placement. This prevents an uncommitted drag from disappearing on
|
||||
Finish or snapping back on Cancel.
|
||||
|
||||
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
|
||||
``_cancel_targets`` from their ``_execute`` method."""
|
||||
|
||||
pset_name: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
|
||||
obj = context.active_object
|
||||
return [obj] if obj else []
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element: entity_instance) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _resolve(cls, obj: bpy.types.Object):
|
||||
"""Look up ``(element, props)`` for ``obj`` if it matches this type, else None.
|
||||
|
||||
Common predicate guard for every lifecycle method — collapses the
|
||||
``element = tool.Ifc.get_entity(obj); assert element; if not is_<type>(element): return``
|
||||
triplet into one call."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not cls._is_element_type(element):
|
||||
return None
|
||||
return element, cls._get_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _handle_drift_on_enable(cls, obj: bpy.types.Object) -> None:
|
||||
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
@classmethod
|
||||
def _handle_drift_on_finish(cls, obj: bpy.types.Object) -> None:
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
|
||||
@classmethod
|
||||
def _handle_drift_on_cancel(cls, obj: bpy.types.Object, element: entity_instance) -> None:
|
||||
tool.Geometry.restore_or_rebaseline_placement(obj, element)
|
||||
|
||||
@classmethod
|
||||
def _mark_type_thumbnail_dirty(cls, element: entity_instance) -> None:
|
||||
"""Mark the element's type's preview thumbnail for refresh so the
|
||||
property-panel preview reflects post-edit geometry. No-op for
|
||||
occurrences without a backing type."""
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
tool.Model.mark_thumbnail_for_update(element_type)
|
||||
|
||||
|
||||
class FeatureModifierEditMixin(ParametricEditMixinBase):
|
||||
"""Lifecycle for door- and window-style parametric modifier operators.
|
||||
|
||||
Enable:
|
||||
Read BBIM_<Type> pset JSON → unwrap ``lining_properties`` and
|
||||
``panel_properties`` → merge constituents data → set draft props →
|
||||
``is_editing = True``.
|
||||
|
||||
Finish:
|
||||
Gather ``general / lining / panel`` kwargs (project units) → nest →
|
||||
``is_editing = False`` → call ``_update_modifier_representation`` →
|
||||
mark thumbnail → write back to BBIM_<Type> pset via
|
||||
``ifcopenshell.api.pset.edit_pset``.
|
||||
|
||||
Cancel:
|
||||
Read BBIM_<Type> pset JSON → unwrap → restore draft props →
|
||||
``switch_representation`` to the Body representation →
|
||||
``is_editing = False``."""
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Hook: call the per-type ``update_<type>_modifier_representation``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _enable_one(cls, obj: bpy.types.Object) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
cls._handle_drift_on_enable(obj)
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
data.update(tool.Model.get_constituents_props_data(element))
|
||||
# required since the pset can be loaded from .ifc and the PropertyGroup
|
||||
# would otherwise still hold its default values
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
|
||||
@classmethod
|
||||
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True)
|
||||
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
|
||||
cls._update_modifier_representation(obj, context)
|
||||
cls._mark_type_thumbnail_dirty(element)
|
||||
tool.Pset.write_bbim_data(element, cls.pset_name, data)
|
||||
cls._handle_drift_on_finish(obj)
|
||||
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
|
||||
props.is_editing = False
|
||||
|
||||
@classmethod
|
||||
def _cancel_one(cls, obj: bpy.types.Object) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
# Cancel must always clear is_editing — leaving it True after a
|
||||
# restore-failure would block the user from re-entering edit mode and
|
||||
# the next save's stale-flag heal would silently roll back the
|
||||
# cancellation. Wrap the restore in try/finally so the flag flips
|
||||
# even on partial failure.
|
||||
try:
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
|
||||
cls._handle_drift_on_cancel(obj, element)
|
||||
finally:
|
||||
props.is_editing = False
|
||||
|
||||
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._enable_one(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._finish_one(obj, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._cancel_one(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PathPreservingEditMixin(ParametricEditMixinBase):
|
||||
"""Lifecycle for railing- and roof-style parametric modifier operators.
|
||||
|
||||
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
|
||||
user-editable through this lifecycle — it survives the edit untouched, only
|
||||
general kwargs are diffed. (Path editing has its own separate operator
|
||||
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
|
||||
|
||||
Enable:
|
||||
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` → set
|
||||
draft props → ``is_editing = True``. The subclass post-load hook
|
||||
can reshape the dict to fit the PropertyGroup's storage layout
|
||||
(e.g., pre-serialise a structured pset value to JSON for a
|
||||
``StringProperty`` field).
|
||||
|
||||
Finish:
|
||||
Read fresh pset → keep ``path_data`` → gather ``general`` kwargs
|
||||
(project units) → reassemble → ``is_editing = False`` → call
|
||||
``_update_pset`` (per-type pset writer) → call ``_update_modifier_ifc_data``
|
||||
(per-type geometry commit).
|
||||
|
||||
Cancel:
|
||||
Read fresh pset → restore draft props → call
|
||||
``_restore_viewport_after_cancel`` (per-type viewport restore — typically
|
||||
rebuilds the bmesh preview, but subclasses may load a different
|
||||
representation entirely) → ``is_editing = False``."""
|
||||
|
||||
@classmethod
|
||||
def _post_load_data(cls, data: dict) -> dict:
|
||||
"""Hook: optionally transform the pset data dict after loading and before
|
||||
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
|
||||
|
||||
Override when the PropertyGroup stores a structured pset field as a
|
||||
serialised primitive — e.g., a list/dict value mapped onto a
|
||||
``StringProperty`` requires JSON-encoding here."""
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element: entity_instance, data: dict) -> None:
|
||||
"""Hook: per-type pset writer (``update_bbim_<type>_pset``)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Hook: per-type ``update_<type>_modifier_ifc_data`` — commits the
|
||||
modified geometry to IFC. Signature accepts ``(obj, context)`` so
|
||||
subclasses can forward either argument to their existing helper."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Hook: restore the viewport mesh to match the just-restored draft props.
|
||||
|
||||
Most subclasses rebuild a bmesh preview from props. Subclasses whose
|
||||
committed IFC representation diverges from the preview may switch
|
||||
the mesh back to the committed representation instead."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _enable_one(cls, obj: bpy.types.Object) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
_element, props = resolved
|
||||
cls._handle_drift_on_enable(obj)
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
|
||||
data = cls._post_load_data(data)
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
|
||||
@classmethod
|
||||
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
|
||||
stored = pset_data["data_dict"]
|
||||
data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
data["path_data"] = stored["path_data"]
|
||||
# Skip the pset commit when the draft is identical to the stored pset:
|
||||
# an Enable → Finish-without-changes cycle should not pollute the
|
||||
# representation list or burn an undo entry. Drift commit still runs
|
||||
# unconditionally — matrix_world drift is independent of pset content.
|
||||
if data != stored:
|
||||
cls._update_pset(element, data)
|
||||
cls._update_modifier_ifc_data(obj, context)
|
||||
cls._mark_type_thumbnail_dirty(element)
|
||||
cls._handle_drift_on_finish(obj)
|
||||
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
|
||||
props.is_editing = False
|
||||
|
||||
@classmethod
|
||||
def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
try:
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
|
||||
stored = pset_data["data_dict"]
|
||||
draft = props.get_general_kwargs(convert_to_project_units=True)
|
||||
draft["path_data"] = stored["path_data"]
|
||||
nothing_changed = draft == stored
|
||||
data = cls._post_load_data(stored)
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
# Skip the viewport rebuild on a no-op cancel: the mesh on screen is
|
||||
# still the committed representation, and the per-type viewport-restore
|
||||
# hook may be expensive (some subclasses reload a high-poly IFC
|
||||
# representation rather than rebuild a preview mesh).
|
||||
if not nothing_changed:
|
||||
cls._restore_viewport_after_cancel(obj, context)
|
||||
cls._handle_drift_on_cancel(obj, element)
|
||||
finally:
|
||||
# Always clear the flag — see ``FeatureModifierEditMixin._cancel_one``
|
||||
# for the rationale.
|
||||
props.is_editing = False
|
||||
|
||||
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._enable_one(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._finish_one(obj, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._cancel_one(obj, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --- Type-selection mixins (Cycle / Pick) ------------------------------------
|
||||
|
||||
|
||||
class TypeAccessorBase:
|
||||
"""Shared contract for operators that resolve and write a Literal type
|
||||
attribute on a Bonsai PropertyGroup.
|
||||
|
||||
Subclasses define ``element_checker``, ``props_getter``, ``type_literal``,
|
||||
``type_attr``; ``skip_element_check`` bypasses element validation. Concrete
|
||||
subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction
|
||||
shape on top.
|
||||
|
||||
Test doubles must be set on the operator instance — the predicates are
|
||||
bound at class-definition time, so patching the underlying tool module
|
||||
has no effect."""
|
||||
|
||||
element_checker: Callable[[entity_instance], bool]
|
||||
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup]
|
||||
type_literal: type
|
||||
type_attr: str
|
||||
skip_element_check: bool = False
|
||||
|
||||
def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None:
|
||||
"""Return the active object iff it passes ``element_checker`` (or the
|
||||
check is skipped). ``None`` signals the operator should bail with
|
||||
``{'CANCELLED'}``."""
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return None
|
||||
if not self.skip_element_check:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not self.element_checker(element):
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
class CycleTypeMixin(TypeAccessorBase):
|
||||
"""Operator mixin that cycles through ``type_literal``'s values.
|
||||
|
||||
Shift-click reverses direction."""
|
||||
|
||||
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
self.reverse = event.shift
|
||||
return self.execute(context)
|
||||
|
||||
def _cycle_type(self, context: bpy.types.Context) -> set[str]:
|
||||
obj = self._resolve_target(context)
|
||||
if obj is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = self.props_getter(obj)
|
||||
types = get_args(self.type_literal)
|
||||
current = getattr(props, self.type_attr)
|
||||
idx = types.index(current) if current in types else 0
|
||||
direction = -1 if self.reverse else 1
|
||||
setattr(props, self.type_attr, types[(idx + direction) % len(types)])
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickTypeMixin(TypeAccessorBase):
|
||||
"""Operator mixin that opens a popup menu listing ``type_literal``'s values.
|
||||
|
||||
Empty ``value`` ⇒ ``invoke`` opens the popup; non-empty ⇒ the user picked
|
||||
an item and ``_pick_type`` applies it.
|
||||
|
||||
When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the
|
||||
menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise
|
||||
the still-pressed click flows straight into Blender's drag-through-pick
|
||||
gesture and the menu commits whichever item the cursor drifts over on
|
||||
release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6
|
||||
redo) bypass the wait and open the menu immediately.
|
||||
|
||||
The ``value`` StringProperty is declared on this mixin but registered via
|
||||
the concrete Operator subclass's MRO scan — do not instantiate the mixin
|
||||
standalone."""
|
||||
|
||||
# Carries the picked value through invoke→execute; empty default
|
||||
# distinguishes "open popup" from "apply".
|
||||
value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
"""Open the picker menu, or apply a value that was preset by a
|
||||
menu-item click.
|
||||
|
||||
Routing through ``execute()`` keeps subclass IFC-transaction wrapping
|
||||
in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path."""
|
||||
if self.value:
|
||||
return self.execute(context)
|
||||
|
||||
if self._resolve_target(context) is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if event.value == "PRESS":
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
return self._open_picker(context)
|
||||
|
||||
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
||||
self._open_picker(context)
|
||||
# INTERFACE does not remove a modal handler; only FINISHED /
|
||||
# CANCELLED do.
|
||||
return {"CANCELLED"}
|
||||
if event.type in {"RIGHTMOUSE", "ESC"}:
|
||||
return {"CANCELLED"}
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _open_picker(self, context: bpy.types.Context) -> set[str]:
|
||||
bl_idname = self.bl_idname
|
||||
values = list(get_args(self.type_literal))
|
||||
|
||||
def draw(menu_self, _menu_context):
|
||||
layout = menu_self.layout
|
||||
for v in values:
|
||||
op = layout.operator(bl_idname, text=v)
|
||||
op.value = v
|
||||
|
||||
context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL")
|
||||
# The type change is a two-step interaction: this invocation just OPENS
|
||||
# the menu (no state change yet); a SECOND invocation fires when the
|
||||
# user clicks a menu item — that one writes ``props.<type_attr>`` and
|
||||
# returns FINISHED. By returning INTERFACE here (and not FINISHED), the
|
||||
# menu-open step is excluded from Blender's undo stack so the user
|
||||
# gets exactly ONE undo entry per type change. If we returned FINISHED
|
||||
# here too, the stack would gain a no-op "opened the menu" entry that
|
||||
# Ctrl+Z would dismiss before reverting the actual type change —
|
||||
# confusing UX where the first Ctrl+Z appears to do nothing.
|
||||
return {"INTERFACE"}
|
||||
|
||||
def _pick_type(self, context: bpy.types.Context) -> set[str]:
|
||||
if not self.value:
|
||||
# No-op rather than re-open the menu, so command-palette misuse
|
||||
# doesn't infinite-loop.
|
||||
return {"CANCELLED"}
|
||||
|
||||
obj = self._resolve_target(context)
|
||||
if obj is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if self.value not in get_args(self.type_literal):
|
||||
self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = self.props_getter(obj)
|
||||
setattr(props, self.type_attr, self.value)
|
||||
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``
|
||||
# (wired into ``bim/handler.py:undo_post`` and ``redo_post``) so the preview
|
||||
# mesh of an in-progress parametric draft repaints after Ctrl+Z / Ctrl+Shift+Z.
|
||||
#
|
||||
# Each regenerator is a one-line lazy-import + call. Lazy imports because
|
||||
# ``bonsai.bim.parametric_lifecycle`` loads before ``bim/module/model/*``
|
||||
# at addon enable; a module-level import would cycle. Each function-local
|
||||
# import lands at first call, after the feature module has registered.
|
||||
#
|
||||
# Types with no entry — door, window, railing, etc. — are IFC-derived: undo
|
||||
# of an IFC mutation already restores the entity, and ``switch_representation``
|
||||
# repaints the mesh as a side effect of the next refresh. They don't need a
|
||||
# bespoke preview regenerator.
|
||||
|
||||
|
||||
def _wall_undo_regenerator(obj: bpy.types.Object) -> None:
|
||||
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
|
||||
|
||||
regenerate_wall_mesh_from_props(obj)
|
||||
|
||||
|
||||
def _stair_undo_regenerator(obj: bpy.types.Object) -> None:
|
||||
from bonsai.bim.module.model.stair import regenerate_stair_mesh
|
||||
|
||||
regenerate_stair_mesh(obj)
|
||||
|
||||
|
||||
def _roof_undo_regenerator(obj: bpy.types.Object) -> None:
|
||||
from bonsai.bim.module.model.roof import update_roof_modifier_bmesh
|
||||
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
|
||||
UNDO_REGENERATORS: dict[str, Callable[[bpy.types.Object], None]] = {
|
||||
"wall": _wall_undo_regenerator,
|
||||
"stair": _stair_undo_regenerator,
|
||||
"roof": _roof_undo_regenerator,
|
||||
}
|
||||
|
||||
|
||||
def resync_parametric_drafts_after_undo() -> None:
|
||||
"""Re-render preview meshes for every parametric draft currently active.
|
||||
|
||||
Walks all objects, skips any not in a registered parametric edit,
|
||||
dispatches to the per-type regenerator in ``UNDO_REGENERATORS``. A type
|
||||
without an entry is left alone — its preview is either already correct
|
||||
(IFC-derived) or has no draft preview mesh."""
|
||||
for obj in bpy.data.objects:
|
||||
feature = tool.Parametric.is_object_editing(obj)
|
||||
if feature is None:
|
||||
continue
|
||||
regenerator = UNDO_REGENERATORS.get(feature.name)
|
||||
if regenerator is None:
|
||||
continue
|
||||
regenerator(obj)
|
||||
tool.Blender.update_all_viewports()
|
||||
|
||||
|
||||
@persistent
|
||||
def _resync_on_undo(scene: bpy.types.Scene) -> None:
|
||||
resync_parametric_drafts_after_undo()
|
||||
|
||||
|
||||
def install_parametric_lifecycle_handlers() -> None:
|
||||
"""Append the undo-resync callback to undo_post and redo_post; idempotent.
|
||||
|
||||
Caller must invoke this AFTER appending the central undo/redo handlers so
|
||||
regenerators see restored IFC state — bpy.app.handlers fire in append order."""
|
||||
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
|
||||
if _resync_on_undo not in hook:
|
||||
hook.append(_resync_on_undo)
|
||||
|
||||
|
||||
def uninstall_parametric_lifecycle_handlers() -> None:
|
||||
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
|
||||
try:
|
||||
hook.remove(_resync_on_undo)
|
||||
except ValueError:
|
||||
pass
|
||||
+209
-126
@@ -15,8 +15,6 @@
|
||||
#
|
||||
# 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 os
|
||||
import platform
|
||||
@@ -39,10 +37,21 @@ 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 import prop as _model_prop
|
||||
from bonsai.bim.module.model import ui as _model_ui
|
||||
from bonsai.bim.module.model.prop import (
|
||||
BIMDoorProperties,
|
||||
BIMRailingProperties,
|
||||
BIMRoofProperties,
|
||||
BIMStairProperties,
|
||||
BIMWindowProperties,
|
||||
)
|
||||
from bonsai.bim.module.model.ui import (
|
||||
draw_door_properties,
|
||||
draw_railing_properties,
|
||||
draw_roof_properties,
|
||||
draw_stair_properties,
|
||||
draw_window_properties,
|
||||
)
|
||||
from bonsai.bim.module.pset.prop import IfcProperty
|
||||
from bonsai.bim.prop import Attribute
|
||||
|
||||
@@ -264,32 +273,130 @@ 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 GizmoPreferences(bpy.types.PropertyGroup):
|
||||
"""Aggregator for parametric gizmo visibility settings. One flat bool per
|
||||
parametric feature; controls whether that feature's gizmo group polls
|
||||
visible in the viewport.
|
||||
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
|
||||
"""Property group for door gizmo visibility settings."""
|
||||
|
||||
The per-feature ``<name>: BoolProperty`` fields are derived from
|
||||
``tool.Parametric.EDIT_TYPES`` at module load — adding a new parametric
|
||||
type to the registry automatically surfaces its toggle here, with no
|
||||
parallel hand-maintained list to keep in sync."""
|
||||
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 GizmoPreferences(bpy.types.PropertyGroup):
|
||||
"""Property group for all gizmo visibility settings."""
|
||||
|
||||
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)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
draw_gizmos_in_3d_viewport: bool
|
||||
|
||||
|
||||
for _gizmo_pref_entry in tool.Parametric.EDIT_TYPES:
|
||||
GizmoPreferences.__annotations__[_gizmo_pref_entry.name] = BoolProperty(
|
||||
name=_gizmo_pref_entry.name.replace("_", " ").title(),
|
||||
default=True,
|
||||
)
|
||||
del _gizmo_pref_entry
|
||||
door: GizmoPreferencesDoor
|
||||
window: GizmoPreferencesWindow
|
||||
stair: GizmoPreferencesStair
|
||||
|
||||
|
||||
class DocPreferences(bpy.types.PropertyGroup):
|
||||
@@ -385,22 +492,11 @@ class DocPreferences(bpy.types.PropertyGroup):
|
||||
|
||||
|
||||
class DefaultParameters(bpy.types.PropertyGroup):
|
||||
"""Per-type preset values used to seed new parametric instances.
|
||||
|
||||
The ``<name>: PointerProperty`` fields are derived from the subset of
|
||||
``tool.Parametric.EDIT_TYPES`` flagged ``has_default_parameters=True``,
|
||||
each pointing at the matching ``BIM<Name>Properties`` class. Adding a
|
||||
new entry with that flag automatically surfaces a preferences section
|
||||
and gives the create operator a preset to copy from."""
|
||||
|
||||
|
||||
for _default_params_entry in tool.Parametric.EDIT_TYPES:
|
||||
if not _default_params_entry.has_default_parameters:
|
||||
continue
|
||||
DefaultParameters.__annotations__[_default_params_entry.name] = bpy.props.PointerProperty(
|
||||
type=getattr(_model_prop, _default_params_entry.props_attr),
|
||||
)
|
||||
del _default_params_entry
|
||||
door: bpy.props.PointerProperty(type=BIMDoorProperties)
|
||||
window: bpy.props.PointerProperty(type=BIMWindowProperties)
|
||||
railing: bpy.props.PointerProperty(type=BIMRailingProperties)
|
||||
roof: bpy.props.PointerProperty(type=BIMRoofProperties)
|
||||
stair: bpy.props.PointerProperty(type=BIMStairProperties)
|
||||
|
||||
|
||||
class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
@@ -512,15 +608,6 @@ 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",
|
||||
@@ -757,13 +844,54 @@ 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()
|
||||
annotations = type(self.gizmos).__annotations__
|
||||
for feature in tool.Parametric.EDIT_TYPES:
|
||||
if feature.name in annotations:
|
||||
box.prop(self.gizmos, feature.name)
|
||||
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)
|
||||
|
||||
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
door_gizmos = self.gizmos.door
|
||||
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
|
||||
# Add special gizmos not in dimension_gizmo_props
|
||||
gizmo_prop_names.update(("swing_arc", "flip_arc"))
|
||||
try:
|
||||
annotations = door_gizmos.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(door_gizmos).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in gizmo_prop_names:
|
||||
layout.prop(door_gizmos, prop)
|
||||
|
||||
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.window import GizmoWindowEdition
|
||||
|
||||
window_gizmos = self.gizmos.window
|
||||
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
|
||||
try:
|
||||
annotations = window_gizmos.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(window_gizmos).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in gizmo_prop_names:
|
||||
layout.prop(window_gizmos, prop)
|
||||
|
||||
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
stair_gizmos = self.gizmos.stair
|
||||
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
|
||||
# Add special gizmos not in dimension_gizmo_props
|
||||
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
|
||||
try:
|
||||
annotations = stair_gizmos.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(stair_gizmos).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in gizmo_prop_names or prop in special_gizmo_names:
|
||||
layout.prop(stair_gizmos, prop)
|
||||
|
||||
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "occurrence_name_style")
|
||||
@@ -810,29 +938,39 @@ 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()
|
||||
for entry in tool.Parametric.EDIT_TYPES:
|
||||
if not entry.has_default_parameters:
|
||||
continue
|
||||
props = getattr(self.default_parameters, entry.name)
|
||||
draw_props = getattr(_model_ui, f"draw_{entry.name}_properties")
|
||||
bonsai.bim.helper.draw_expandable_panel(
|
||||
box,
|
||||
context,
|
||||
entry.name.replace("_", " ").title(),
|
||||
lambda _layout, _context, _draw=draw_props, _props=props: _draw(_layout, _props),
|
||||
)
|
||||
bonsai.bim.helper.draw_expandable_panel(
|
||||
box,
|
||||
context,
|
||||
"Door",
|
||||
lambda _layout, _context: draw_door_properties(_layout, self.default_parameters.door),
|
||||
)
|
||||
bonsai.bim.helper.draw_expandable_panel(
|
||||
box,
|
||||
context,
|
||||
"Window",
|
||||
lambda _layout, _context: draw_window_properties(_layout, self.default_parameters.window),
|
||||
)
|
||||
bonsai.bim.helper.draw_expandable_panel(
|
||||
box,
|
||||
context,
|
||||
"Railing",
|
||||
lambda _layout, _context: draw_railing_properties(_layout, self.default_parameters.railing),
|
||||
)
|
||||
bonsai.bim.helper.draw_expandable_panel(
|
||||
box,
|
||||
context,
|
||||
"Roof",
|
||||
lambda _layout, _context: draw_roof_properties(_layout, self.default_parameters.roof),
|
||||
)
|
||||
bonsai.bim.helper.draw_expandable_panel(
|
||||
box,
|
||||
context,
|
||||
"Stair",
|
||||
lambda _layout, _context: draw_stair_properties(_layout, self.default_parameters.stair),
|
||||
)
|
||||
|
||||
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "opening_focus_opacity")
|
||||
@@ -950,50 +1088,6 @@ 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"):
|
||||
@@ -1945,7 +2039,6 @@ 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()
|
||||
@@ -1963,20 +2056,10 @@ 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):
|
||||
|
||||
@@ -93,8 +93,6 @@ def enter_aggregate_mode(
|
||||
aggregator: type[tool.Aggregate],
|
||||
obj: bpy.types.Object,
|
||||
):
|
||||
if not aggregator.get_aggregate_props().in_aggregate_mode:
|
||||
aggregator.save_previous_selection()
|
||||
aggregator.update_previous_aggregate_mode_state()
|
||||
if aggregator.get_higher_aggregate():
|
||||
aggregator.disable_aggregate_mode()
|
||||
@@ -109,7 +107,6 @@ def exit_aggregate_mode(aggregator: type[tool.Aggregate]):
|
||||
aggregator.enable_aggregate_mode(new_obj)
|
||||
else:
|
||||
aggregator.disable_aggregate_mode()
|
||||
aggregator.restore_previous_selection()
|
||||
|
||||
|
||||
class IncompatibleAggregateError(Exception):
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# 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}")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user