Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Schultz 0d39a21bf4 closes #6235 - Add copy toggle to CAD offset
Add a "Copy" option to bim.cad_offset. When enabled (the
default) it offsets a new copy of the selected edges as
before; when disabled it moves the existing edges to the
offset location instead. The toggle is exposed in the CAD
tool's Offset panel and the operator redo panel.

Generated with the assistance of an AI coding tool.
2026-06-12 14:02:44 -05:00
479 changed files with 4428 additions and 27256 deletions
+3 -2
View File
@@ -109,7 +109,7 @@ jobs:
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
# Download Blender.
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.2/blender-5.2.0-linux-x64.tar.xz
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz
tar -xf blender.tar.xz
# Setup Blender.
@@ -179,7 +179,8 @@ jobs:
blender --online-mode --command extension install --enable --sync sun_position
cd IfcOpenShell/src/bonsai
pip install -r requirements-dev.txt
pip install pytest-blender
pip install pytest-bdd
blender --background --python scripts/setup_pytest.py
blender --python-expr "import bonsai; print(bonsai.bbim_semver); import ifcopenshell; print(ifcopenshell.version)" --background
make test
-155
View File
@@ -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
+11 -17
View File
@@ -27,7 +27,10 @@ jobs:
- name: Install dependencies
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
cat requirements-tools.txt | xargs -L1 uv tool install
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty==0.0.34
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
@@ -55,17 +58,11 @@ jobs:
black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py
continue-on-error: true
- name: ty check (venv setup)
run: poe ty-venv
- name: ty check (bonsai)
id: ty-bonsai
run: poe ty-bonsai
continue-on-error: true
- name: ty check (ios)
id: ty-ios
run: poe ty-ios
- name: ty check
id: ty
run: |
poe ty-venv
poe ty
continue-on-error: true
- name: Ruff check
@@ -115,10 +112,7 @@ jobs:
if [ "${{ steps.ruff.outcome }}" != "success" ]; then
echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1
fi
if [ "${{ steps.ty-bonsai.outcome }}" != "success" ]; then
echo "::error::ty check (bonsai) failed, see 'ty check (bonsai)' step for the details." && ERROR=1
fi
if [ "${{ steps.ty-ios.outcome }}" != "success" ]; then
echo "::error::ty check (ios) failed, see 'ty check (ios)' step for the details." && ERROR=1
if [ "${{ steps.ty.outcome }}" != "success" ]; then
echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1
fi
exit $ERROR
+3 -20
View File
@@ -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 pyparsing
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;
-6
View File
@@ -4,8 +4,6 @@
/_deps-vs*-x*-installed/
/_installed-vs*-x*/
/build/
/build.log
/output/
/src/examples/build/
# ifctester docs output
/src/ifctester/test/build/
@@ -24,14 +22,12 @@
__pycache__
*.py.bak
venv
uv.lock
# Visual Studio Code files
.vscode
!.vscode/launch.json
!.vscode/tasks.json
.vs
/*.code-workspace
# PyCharm files
.idea
@@ -130,7 +126,5 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# temp files from AI coding tools
*.claude
CLAUDE.local.md
*.py.tmp*
*.json.tmp*
+1 -1
View File
@@ -18,7 +18,7 @@ and many other libraries, CLI apps, and more. Support is also provided for auxil
For more information, see:
* [IfcOpenShell Website](https://ifcopenshell.org)
* [IfcOpenShell Website](http://ifcopenshell.org)
* [IfcOpenShell Documentation](https://docs.ifcopenshell.org)
* [IfcOpenShell C++ Installation](https://docs.ifcopenshell.org/ifcopenshell/installation.html)
* [IfcOpenShell Python Installation](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html)
+11 -21
View File
@@ -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)
@@ -314,12 +313,8 @@ if(WASM_BUILD)
else()
# @todo review this, shouldn't this be all possible header-only now?
# ... or rewritten using C++17 features?
# Boost.System has been header-only since 1.69 and its compiled stub library
# was dropped in newer Boost, so requesting it as a component makes
# find_package fail on Boost 1.70 and up (for example Boost 1.90). It is
# still pulled in transitively by thread / iostreams where needed, so do not
# request it explicitly.
set(BOOST_COMPONENTS
system
program_options
regex
thread
@@ -563,8 +558,8 @@ if(COMPILE_SCHEMA)
# Bootstrap the parser
message(STATUS "Compiling schema, this will take a while...")
execute_process(
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py
WORKING_DIRECTORY ../src/ifcopenshell-python/ifcopenshell/express
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py express.bnf
WORKING_DIRECTORY ../src/ifcexpressparser
OUTPUT_FILE express_parser.py
RESULT_VARIABLE SUCCESS
)
@@ -575,7 +570,7 @@ if(COMPILE_SCHEMA)
# Generate code
execute_process(
COMMAND ${PYTHON_EXECUTABLE} ../ifcopenshell-python/ifcopenshell/express/express_parser.py ../../${COMPILE_SCHEMA}
COMMAND ${PYTHON_EXECUTABLE} ../ifcexpressparser/express_parser.py ../../${COMPILE_SCHEMA}
WORKING_DIRECTORY ../src/ifcparse
OUTPUT_VARIABLE COMPILED_SCHEMA_NAME
)
@@ -665,11 +660,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")
-131
View File
@@ -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)
+4 -38
View File
@@ -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")
-3
View File
@@ -1,3 +0,0 @@
.env
*.pyc
__pycache__
-3
View File
@@ -1,3 +0,0 @@
.env
*.pyc
__pycache__
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# .ifcos_env
# register autocompletes. just source the file in your shell, i.e.
# source .ifcos_env
.ifcos_env() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
opts="create update up down restart build attach logs ps config remove help"
# Basic static completion
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
}
# Register the completion for the command "ifcos_env"
complete -F .ifcos_env ./ifcos_env
-67
View File
@@ -1,67 +0,0 @@
FROM rockylinux:9
# Update system, enable CRB (needed by some EPEL packages) and install EPEL,
# then install required packages + some common tools for a bit of command
# line comfort. Combined into one layer so a later `create` always installs
# against packages from the same dnf update, rather than layering fresh
# installs on top of a stale cached "update" layer.
RUN dnf update -y && \
dnf install -y epel-release && \
dnf config-manager --set-enabled crb && \
dnf install -y --allowerasing --setopt=install_weak_deps=False --setopt=tsflags=nodocs \
bash-completion vim git curl wget which tree htop sudo \
gcc gcc-c++ autoconf automake bison make zip cmake \
python3 python3-pip \
bzip2 patch mesa-libGL-devel libffi-devel fontconfig-devel \
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libuuid-devel git-lfs \
findutils xz byacc ccache && \
git lfs install --system && \
dnf clean all && \
rm -rf /var/cache/dnf
# Trust bind-mounted repos regardless of which user (root or builder) or host
# UID owns them, rather than a per-user config that only one of them sees.
RUN git config --system --add safe.directory '*'
# Configure ccache. CCACHE_MAXSIZE (not `ccache -M`) because /ccache is a
# volume mount point at runtime - anything `ccache -M` writes to a config
# file under it during this build gets shadowed once the real volume is
# mounted, so the size cap only actually takes effect via the env var.
# 2G is generous: a full build (IfcParse+IfcGeom+IfcConvert+wrapper, one
# Python version) measures ~300MB, and the volume is now shared across all
# checkouts (see compose.yaml), so this covers several diverging branches.
ENV CCACHE_DIR=/ccache
ENV CCACHE_MAXSIZE=2G
ENV PATH="/usr/lib/ccache:$PATH"
# Non-root user matching the host UID/GID that bind-mounts the repo (default
# 1000:1000, the common single-user-Linux-box case), so files the build
# creates under the mount keep sane, non-root ownership on the host side.
# Override with --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g)
# if your host user has a different UID/GID.
ARG USER_UID=1000
ARG USER_GID=1000
# groupadd fails outright if USER_GID is already taken by an existing
# system group - which happens whenever a host's primary GID collides with
# one baked into the rockylinux9 base image. The main real-world case is
# macOS, where the default user's primary group is "staff" at GID 20, and
# GID 20 is "games" on RHEL-family images. Only create the "builder" group
# when that GID is actually free; otherwise useradd just attaches to
# whichever group already owns it. Either way the builder user ends up
# with the right GID for bind-mount ownership, which is all that matters.
RUN (getent group "${USER_GID}" >/dev/null || groupadd -g "${USER_GID}" builder) \
&& useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash builder \
&& echo "builder ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/builder
# Copied while still root: /bin is not writable by the builder user.
COPY --from=ghcr.io/astral-sh/uv:0.11.27 /uv /uvx /bin/
USER builder
WORKDIR /__w/IfcOpenShell/IfcOpenShell
# Installed as builder so managed Python interpreters land under builder's
# $HOME, matching the user that actually runs the build.
RUN uv python install
CMD ["sleep", "infinity"]
-78
View File
@@ -1,78 +0,0 @@
Docker build environment
========================
This is a small utility to make it easy to compile a perfect `_ifcopenshell_wrapper.cpython-*-x86_64-linux-gnu.so`
files.
The reason for this tool is that I was trying to follow the web page directions, and my build was behaving differently
to the release builds. Eventually I concluded that the differences between toolchains on the RHEL based rocky9 image
and Ubuntu were just too great. Getting the build setup was already a lot of trial and error, so I thought I'd spend
more time trying to reuse the github actions that perform the build, using a utility called `act`. I learnt a lot, in
particular how much time, energy, and bandwidth Github waste. I also realised I was most of the way to a regular docker
setup anyway, so I might as well just do that. So I've deconstructed all the github action steps, and turned it into
a local docker build environment that uses the exact same base, tools, libraries, and build command/flags etc.
Right now a Github action will:
- launch the rocky9 base
- upgrade all the packages
- install a bunch of extra tools
- do a recursive checkout of your repo
- checkout the build repository
- unpack dependencies
- run the build script, making all python versions (5? right now I think)
- create the .zip release files
And it does _all_ of that _every_ time. This is not a fault of the action writers - it's just how Github seems to work.
These dockers tools do the following differently, and it's actually a bit more powerful too:
- build the base image once.
- update the packages once.
- install the extra tools once.
- the repository is the one on your host, that gets bind mounted in the container as the working directory.
- by adding an environment variable to .env, restricts to compiling for just a single python version.
- when the build is finished the created files are right there under your local repositry (but not added to git) for
ease of access
- each repository can have it's own build environment container.
- the image is shared between those environments.
- the containers share the ccache, so additional envs should get a helping hand.
- it has a simple set of user friendly commands to drive it all.
For example:
``` bash
# To see the commands (a superset of docker compose commands)
./ifcos_env
# Enable autocomplete of commands
source .ifcos_env
# First time commands
./ifcos_env create
./ifcos_env up
./ifcos_env build
# install and test library
# find an issue
# edit code
./ifcos_env build
# and so on. When done stop and optionally delete the container
./ifcos_env stop
./ifcos_env remove
```
To limit the build to one python version just add
``` bash
PY_TGT=py-311
```
or whichever version your Blender requires.
You might see UNIQUE_ID in the .env file too. This keeps containers for separate folders, separate.
System requirements
1. Linux-x64 only at this time.
2. Docker and docker-compose need to be installed.
3. Have a good amount of disk space. (image is in /var (typically the root partition) and will be about 1.7 GB)
4. The build action will create about 10GB in your repository folder. Make sure this partition is spacious
particularly if you intent on having multiple clones building.
5. ... I think that covers most of it.
-186
View File
@@ -1,186 +0,0 @@
---
name: ifcopenshell-docker-build
description: >-
Build a real ifcopenshell_wrapper (.so + .py) and IfcConvert locally via
the docker/ifcos_env toolchain, then wire them into a checkout for
running C++-dependent parts of the test suite (geometry, the SWIG
wrapper stub, the C++ parser). Use whenever a task needs to compile
IfcOpenShell's C++ core rather than just read/patch source - e.g.
reproducing or fixing a bug in src/ifcgeom, src/ifcparse, src/ifcwrap,
or validating util/scripts/validate_stub.py against the actual
generated wrapper.
---
# Building IfcOpenShell locally with docker/ifcos_env
`docker/` mirrors the project's GitHub Actions build environment locally,
in a persistent, non-root container with ccache so repeat builds are fast.
See `docker/README.md` for the design rationale. Pure-Python changes don't
need any of this - only reach for it when you need a real compiled
`_ifcopenshell_wrapper*.so` or `IfcConvert` binary.
## Placement
This `docker/` folder must live as a direct child of the repo root you want
to build (sibling of `src/`, `cmake/`, etc.) - `compose.yaml` and
`ifcos_env` resolve the repo via `../` relative to wherever `docker/`
itself sits, and bind-mount it into the container. If you're setting this
up in a fresh clone, copy the whole `docker/` directory there first.
## Setup
```bash
cd docker
./ifcos_env create # build the image (shared by name across all your clones/checkouts, so usually instant after the first time anywhere)
./ifcos_env up # create + start the container, clone/unpack the third-party dependency cache (~10GB, one-time per container)
./ifcos_env build # full build: all deps + IfcParse + IfcGeom + IfcConvert + the Python wrapper, for one Python version
```
`PY_TGT` and `UNIQUE_ID` live in `docker/.env` - `PY_TGT` (e.g. `py-311`)
restricts the build to one Python version instead of building five;
`UNIQUE_ID` is a hash of the folder path, recalculated on every `up`, so
each checkout gets its own container/volumes automatically.
A full first build takes ~1.5 hours (mostly compiling IfcOpenShell's own
C++, not the cached third-party deps). After that, ccache makes incremental
rebuilds of a couple of touched `.cpp` files **under a minute**.
## Container lifecycle
The container is long-lived (`sleep infinity`) so exec'd commands and
ccache state persist between builds. Commands map directly onto Docker
Compose's own container-vs-image distinction:
```bash
./ifcos_env up # create the container if it doesn't exist, then start it (runs ready_repo too)
./ifcos_env stop # stop the container, keep it around
./ifcos_env start # start it back up (same container, same filesystem layer)
./ifcos_env restart # stop, then start
./ifcos_env down # remove the container (and its network) entirely
./ifcos_env recreate # down, then up - a fresh container
```
Named volumes (`ccache`) and the bind-mounted repo/`build/` are unaffected
by `down`/`recreate` - only the container itself goes away, and `up`
recreates it from the image.
## Fast iteration
Pass a target to `build` to skip the parts you don't need:
```bash
./ifcos_env build IfcConvert # only the executables (IfcConvert, IfcGeomServer) - skips the Python wrapper entirely
./ifcos_env build IfcOpenShell-Python # only the SWIG Python wrapper - skips executables entirely
./ifcos_env build # no target = everything (needed the first time, or after touching shared headers)
```
Use this to keep the edit -> rebuild -> test loop fast when debugging: if
you're only touching `src/ifcgeom/`, build `IfcConvert`; if you're only
exercising the Python API, build `IfcOpenShell-Python`.
## Where the artifacts land
Build output goes to `<repo_root>/build/Linux/x86_64/install/` on the host
(bind-mounted, not just inside the container), owned by you (see
"Container user" below):
- `ifcopenshell/bin/IfcConvert` - the CLI binary
- `python-<version>/lib/python<X.Y>/site-packages/ifcopenshell/_ifcopenshell_wrapper*.so`
and `ifcopenshell_wrapper.py` - the compiled wrapper + its generated
Python glue
## Testing against a checkout (automated / AI-driven)
`_ifcopenshell_wrapper*.so` and `ifcopenshell_wrapper.py` are already
gitignored under `src/ifcopenshell-python/ifcopenshell/`, which is exactly
where a normal in-tree build would put them - copy the two files there:
```bash
SRC=build/Linux/x86_64/install/python-3.11.8/lib/python3.11/site-packages/ifcopenshell
cp "$SRC/_ifcopenshell_wrapper.cpython-311-x86_64-linux-gnu.so" src/ifcopenshell-python/ifcopenshell/
cp "$SRC/ifcopenshell_wrapper.py" src/ifcopenshell-python/ifcopenshell/
```
Then, to run the test suite against it:
```bash
export PATH="$PWD/build/Linux/x86_64/install/ifcopenshell/bin:$PATH" # for IfcConvert-dependent tests
cd src/ifcopenshell-python/test
PYTHONPATH="$PWD/.." python3.11 -m pytest -p no:pytest-blender .
```
(`-p no:pytest-blender` avoids the pytest-blender plugin trying to find a
`blender` executable and failing collection entirely, even for non-Blender
tests.) You'll need the matching Python version's `pip install`s too
(numpy, shapely, isodate, lark, tabulate, pytest, ... - whatever the
modules under test import) since this is a bare interpreter, not the
project's pixi env.
**This is the pattern to use for automated or AI-driven verification.**
Don't use `try` (below) for that - it overwrites files in a real, live
Blender installation, which isn't something an automated/AI workflow
should ever do without the human explicitly asking for it in the moment.
## Testing in Blender itself (human only)
`try` copies the built wrapper straight into your actual Blender/Bonsai
extension install, for manual in-Blender testing:
```bash
./ifcos_env try
```
It reads `BLENDER_USER_RESOURCE` from `.env` - set this to wherever
Blender's user resource folder for the Bonsai extension actually lives on
your system, which depends on your own Blender setup:
```bash
# in docker/.env
BLENDER_USER_RESOURCE=~/.config/blender/bonsai/
```
`try` figures out the built Python version from `build/.../install/`
(disambiguating with `PY_TGT` if more than one version was built) and
copies the wrapper to
`$BLENDER_USER_RESOURCE/extensions/.local/lib/python<X.Y>/site-packages/ifcopenshell/`.
## Container user
The image runs as a non-root `builder` user, UID/GID matching your host
account (passed as `--build-arg` by `create` from `id -u`/`id -g`, so it
adjusts automatically - no manual flag needed even if you're not 1000:1000).
Files the build creates under the bind mount come out owned by you, not
root. Passwordless `sudo` is available inside the container (e.g. via
`attach`) for the rare case you need root for something ad hoc.
If you're picking up an existing checkout that was previously built with
an older, root-based image, you may hit `Permission denied` the first time
you run `up`/`build` under the new image - `build/`, `.git/modules/`, the
`ccache` volume, `output/`, and `build.log` can all be left root-owned from
before. Fix it once via the container's own root (no host `sudo` needed):
```bash
docker exec -u root -w /__w/IfcOpenShell/IfcOpenShell <container-name> \
chown -R "$(id -u)":"$(id -g)" .git/modules build output build.log /ccache
```
(`<container-name>` is `ifcopenshell-<UNIQUE_ID>` - see `docker ps -a`.)
## Other things worth knowing
- **Linux x64 only.** `compose.yaml` pins `platform: linux/amd64`; on an
ARM host (e.g. Apple Silicon) this build isn't available.
- **The final "Package .zip archives" step of `build()` has a pre-existing
bash syntax error**, unrelated to compilation - the actual build already
succeeded by that point (look for `Built IfcOpenShell...` in the output),
so this is safe to ignore if you only need the raw artifacts under
`build/.../install/`, not packaged release zips.
- **`test_mmaped_stream` and similar `USE_MMAP`-dependent tests will fail**
against this build - `nix/build-all.py` is invoked with `USE_MMAP=OFF`
here. Not a bug in your code if you see it fail.
- Only the bind-mounted `<repo>/build` lives on the host filesystem your
repo is checked out on. Anything the container writes *outside* that
mount lives in the container's own writable layer under Docker's data
root (commonly `/var/lib/docker`, i.e. usually your root partition) -
keep an eye on `df -h /` if you're running several of these containers
at once.
-15
View File
@@ -1,15 +0,0 @@
name: ifcopenshell-${UNIQUE_ID}
services:
ifcopenshell:
container_name: ifcopenshell-${UNIQUE_ID}
image: ifcopenshell-build-env:updated
platform: linux/amd64
volumes:
- type: bind
source: ../
target: /__w/IfcOpenShell/IfcOpenShell
- ccache:/ccache
volumes:
ccache:
name: ifcopenshell-ccache-shared
-339
View File
@@ -1,339 +0,0 @@
#!/bin/bash
# ================== CONFIG ==================
SCRIPT_NAME=$(basename "$0")
ENV_FILE=".env"
WORKDIR="/__w/IfcOpenShell/IfcOpenShell"
NAMEPREFIX=ifcopenshell
function set_env() {
# Load .env file if it exists
if [[ -f "$ENV_FILE" ]]; then
set -a
source "$ENV_FILE"
set +a
echo "✅ Loaded environment variables from $ENV_FILE"
else
echo "⚠️ No $ENV_FILE found, proceeding without it."
fi
}
set_env
# ================ FUNCTIONS =================
function create() {
echo "⭐ Creating image: ifcopenshell-build-env"
docker build -f Dockerfile \
--build-arg USER_UID="$(id -u)" --build-arg USER_GID="$(id -g)" \
-t ifcopenshell-build-env:updated .
}
function update() {
# The Dockerfile always builds FROM a clean rockylinux:9 and does
# `dnf update -y` as its first step, so re-running create() is enough
# to get fresh packages.
echo "⚡ Updating image: ifcopenshell-build-env"
create
}
function up() {
# Creates the container if it doesn't exist yet (and starts it either
# way) - this is the one that needs ready_repo, since a freshly created
# container has no submodules/dependency cache in place yet.
echo "🚀 Creating/starting stack: ifcopenshell-${UNIQUE_ID}"
unique # Update UNIQUE_ID first
docker compose up -d "$@" # Container must exist before ready_repo can exec into it.
ready_repo # Ensure repo is recursive, and the build repo is in place.
}
function down() {
# Removes the container (and its network) entirely. Named volumes
# (ccache) and the bind-mounted repo/build/ survive; up() will recreate
# the container from scratch next time.
echo "🔥 Removing stack: ifcopenshell-${UNIQUE_ID}"
docker compose down "$@"
}
function stop() {
# Stops the existing container without removing it - the container,
# its filesystem layer, and its exec history all remain intact.
echo "🛑 Stopping stack: ifcopenshell-${UNIQUE_ID}"
docker compose stop "$@"
}
function start() {
# Starts a previously-stopped container back up. Does nothing (and
# won't create anything) if the container doesn't exist - use up() for
# that.
echo "▶️ Starting stack: ifcopenshell-${UNIQUE_ID}"
docker compose start "$@"
}
function restart() {
echo "🔄 Restarting stack (stop, then start)..."
stop
start
}
function recreate() {
echo "♻️ Recreating stack (down, then up)..."
down
up
}
function logs() {
echo "📜 Showing logs..."
docker compose logs -f "$@"
}
function ps() {
docker compose ps
}
function config() {
echo "🔍 Validated compose configuration:"
docker compose config
}
function remove() {
# Lower-level than down(): removes already-stopped containers without
# touching the compose network. Mostly useful after a plain stop().
echo "🗑️ Removing stopped containers: ifcopenshell-${UNIQUE_ID}"
docker compose rm "$@"
}
function unique() {
echo "🔧 Making stack name folder specific..."
REGEX="^UNIQUE_ID="
if [[ ! -f "$ENV_FILE" ]] || ! grep -qE "$REGEX" "$ENV_FILE"; then
echo -e "\nUNIQUE_ID=dummy\n" >> "$ENV_FILE"
fi
export UNIQUE_ID="$(pwd | sha256sum | cut -c -8)"
# `sed -i` takes incompatible syntax between GNU sed (Linux) and BSD sed
# (macOS) - `-si` is GNU-only and errors as "illegal option -- s" under
# BSD/macOS sed. Avoid -i altogether and do the in-place edit via a temp
# file + mv instead, which behaves identically with either sed.
local tmp_file
tmp_file="$(mktemp "${ENV_FILE}.XXXXXX")"
sed "s/^UNIQUE_ID=.*$/UNIQUE_ID=${UNIQUE_ID}/" "$ENV_FILE" > "$tmp_file"
mv "$tmp_file" "$ENV_FILE"
set_env
}
function ready_repo() {
echo "👍 Getting the repo ready to build..."
docker exec -i -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
set -euo pipefail # Recommended for robustness
git submodule update --init --recursive
if [[ ! -d "build" ]]; then
git clone -b rockylinux9-x64 https://github.com/IfcOpenShell/build-outputs.git build
else
cd build
git pull
cd ..
fi
if [[ ! -d "build/Linux/x86_64/install/boost-1.86.0/" ]]; then
cd build
uv run ../nix/cache_dependencies.py unpack
cd ..
fi
'
}
function build() {
echo "☕ Execute the build, go make yourself a cuppa... I'll be a while"
local BUILD_TARGET="$1"
docker exec -i -w "${WORKDIR}" -e PY_TGT="${PY_TGT}" -e BUILD_TARGET="${BUILD_TARGET}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v ${PY_TGT:+-$PY_TGT} --diskcleanup ${BUILD_TARGET} 2>&1 | tee build.log
'
echo "🎒 Pack Dependencies"
docker exec -i -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
cd build
uv run ../nix/cache_dependencies.py pack
'
echo "🎁 Package .zip archives"
docker exec -i -w "${WORKDIR}" -e GITHUB_SHA="$(git rev-parse HEAD)" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
OUTPUT_DIR=${PWD}/output
VERSION=v`cat VERSION`
mkdir -p ${OUTPUT_DIR}
cd ./build/`uname`/*/install/ifcopenshell
ls -d python-* | while read py_version; do
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
numbers=`echo $py_version | grep -oE "[0-9]+\.[0-9]+" | tr -d "."`
py_version_major=python-${numbers}$postfix
pushd . > /dev/null
cd $py_version
if [ ! -d ifcopenshell ]; then
mkdir ../ifcopenshell_
mv * ../ifcopenshell_
mv ../ifcopenshell_ ifcopenshell
fi
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
find ifcopenshell -name "*.pyc" -delete
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell/*
mv *.zip ${OUTPUT_DIR}/
popd > /dev/null
done
cd bin
if compgen -G "./*.zip" > /dev/null; then
rm *.zip 2>&1 >/dev/null || true
ls | while read exe; do
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip $exe
done
mv *.zip ${OUTPUT_DIR}/
cd ..
'
}
function attach() {
echo "🔦 Connect to interactive shell"
docker exec -it -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" /bin/bash
}
function try() {
# Copies the freshly built wrapper into your actual Blender/Bonsai
# installation for manual, in-Blender testing. This is a human-only
# convenience: it overwrites files in your live Blender setup, so it's
# not something that should run unattended as part of an automated or
# AI-driven build/test loop (which should instead copy the wrapper into
# the repo's own src/ifcopenshell-python/ifcopenshell/ - see SKILL.md).
echo "🚴 Copying build artifacts into your Blender resource folder for testing"
if [[ -z "${BLENDER_USER_RESOURCE:-}" ]]; then
echo "❌ BLENDER_USER_RESOURCE is not set in .env."
echo " Add a line pointing at wherever Blender's user resource folder for"
echo " the Bonsai extension actually is on your system, e.g.:"
echo " BLENDER_USER_RESOURCE=~/.config/blender/bonsai/"
return 1
fi
# Normalise: expand a leading ~ (in case it was quoted in .env and so
# never went through shell tilde-expansion when set_env sourced it),
# then resolve to an absolute, symlink-free path.
local resource="${BLENDER_USER_RESOURCE/#\~/$HOME}"
resource="$(realpath -m "$resource")"
local install_dir="../build/Linux/x86_64/install"
local py_dirs=("$install_dir"/python-*)
if [[ ${#py_dirs[@]} -gt 1 && -n "${PY_TGT:-}" ]]; then
# PY_TGT is compact (py-311); the install dirs are dotted
# (python-3.11.8) - reinsert the dot (assumes a single-digit major
# version, true for the Python 3.x line) before matching.
local py_tgt_digits="${PY_TGT#py-}"
local py_tgt_dotted="${py_tgt_digits:0:1}.${py_tgt_digits:1}"
local filtered=() d
for d in "${py_dirs[@]}"; do
[[ "$(basename "$d")" == "python-${py_tgt_dotted}."* ]] && filtered+=("$d")
done
[[ ${#filtered[@]} -gt 0 ]] && py_dirs=("${filtered[@]}")
fi
if [[ ${#py_dirs[@]} -ne 1 || ! -d "${py_dirs[0]}" ]]; then
echo "❌ Expected exactly one built python-* dir under $install_dir, found ${#py_dirs[@]}."
echo " Run 'build' first, or set PY_TGT in .env to disambiguate a multi-version build."
return 1
fi
local py_minor
py_minor="$(basename "${py_dirs[0]}" | grep -oE '[0-9]+\.[0-9]+')"
local wrapper_dir="${py_dirs[0]}/lib/python${py_minor}/site-packages/ifcopenshell"
if [[ ! -f "$wrapper_dir/ifcopenshell_wrapper.py" ]]; then
echo "❌ Built wrapper not found at $wrapper_dir - run 'build' first."
return 1
fi
local target="$resource/extensions/.local/lib/python${py_minor}/site-packages/ifcopenshell"
mkdir -p "$target"
cp "$wrapper_dir"/_ifcopenshell_wrapper*.so "$target/"
cp "$wrapper_dir"/ifcopenshell_wrapper.py "$target/"
echo "✅ Copied wrapper into $target"
}
function clean() {
# Host-side only - doesn't touch the container, image, or ccache volume.
echo "💎 Clean the build and output folder up"
if [[ -d "../build" ]]; then
rm -rf ../build
fi
if [[ -d "../output" ]]; then
rm -rf ../output
fi
}
function help() {
cat <<EOF
Usage: ./$SCRIPT_NAME <command>
Available commands:
create Build the rocky9-based image
update Rebuild the image fresh, picking up OS package updates
up Create the container if it doesn't exist yet, and start it
down Remove the container entirely (docker compose down)
stop Stop the container without removing it
start Start a previously-stopped container
restart stop, then start (same container, no recreation)
recreate down, then up (fresh container)
build Execute the IfcOpenShell build
attach Connect to an interactive shell in the container
try Copy the built wrapper into your Blender resource folder
(human-only - see BLENDER_USER_RESOURCE below, and SKILL.md
for the AI/automated-testing equivalent)
clean Remove the build and output folders
logs Follow container logs
ps Show running containers
config Validate and show compose config
remove Remove stopped containers (docker compose rm)
help Show this help
Environment variables from .env are automatically loaded, including:
PY_TGT Restrict the build to one Python version, e.g. py-311
UNIQUE_ID Recalculated automatically on every 'up', don't set by hand
BLENDER_USER_RESOURCE Where 'try' copies the wrapper for manual testing, e.g.
~/.config/blender/bonsai/
EOF
}
# ================= MAIN =================
case "$1" in
create) create ;;
update) update ;;
up) up "${@:2}" ;;
down) down "${@:2}" ;;
stop) stop "${@:2}" ;;
start) start "${@:2}" ;;
restart) restart ;;
recreate) recreate ;;
build) build "${@:2}" ;;
attach) attach ;;
try) try ;;
clean) clean ;;
logs) logs "${@:2}" ;;
ps) ps ;;
config) config ;;
remove) remove ;;
help|-h|--help) help ;;
"")
echo "❌ No command provided."
help
;;
*)
echo "❌ Unknown command: $1"
echo "Type './$SCRIPT_NAME help' for available commands."
exit 1
;;
esac
+5 -15
View File
@@ -50,7 +50,7 @@ Used environment variables:
- ``NO_CLEAN`` - do not clean `ifcopenshell` build directories but continue working on current build
(installed dependencies are never cleared).
By default option is disabled, to enable pass any value from `1`, `on`, `true`.
- ``IFCOS_SCHEMAS`` - schemas to be built; defaults to cmake default (8 schemas), to be supplied as `2x3;4;4x3_add2`
- ``IFCOS_SCHEMAS`` - schemas to be built; defaults to cmake default (IFC2X3; IFC4; IFC4X3_ADD2) - to be supplied as `2x3;4`
- ``USE_OCCT`` - whether to use official Open CASCADE instead of Community Edition
(`true` by default, any other value is considered `false`)
- ``WASM_PYTHON_PATH`` - path to WASM Python installation,
@@ -155,7 +155,7 @@ MPFR_VERSION = "3.1.6" # latest is 4.1.0
CGAL_VERSION = "v5.6.3"
USD_VERSION = "23.05"
TBB_VERSION = "2021.9.0"
ROCKSDB_VERSION = "10.4.2"
ROCKSDB_VERSION = "9.11.2"
ZSTD_VERSION = "1.5.7"
# binaries
cp = "cp"
@@ -627,10 +627,9 @@ def build_dependency(
build_tool_args: "list[str]",
download_url: str,
download_name: str,
*,
download_tool: Literal["py", "git"] = download_tool_default,
revision: "Union[str, None]" = None,
patch: list[str] | None = None,
patch: "Union[str, list[str], None]" = None,
shell=None,
pre_compile_subs: "Sequence[tuple[str, str, str]]" = (),
additional_files: "Union[dict[str, str], None]" = None,
@@ -715,6 +714,8 @@ def build_dependency(
urlretrieve(url, os.path.join(extract_dir, path))
if patch is not None:
if isinstance(patch, str):
patch = [patch]
for p in patch:
patch_abs = (SCRIPT_PATH / p).absolute().__str__()
if os.path.exists(patch_abs):
@@ -723,8 +724,6 @@ def build_dependency(
except Exception as e:
# Assert that the patch has already been applied
run(["patch", "-p1", "--batch", "--reverse", "--dry-run", "-i", patch_abs], cwd=extract_dir)
else:
raise FileNotFoundError(patch_abs)
if shell is not None:
sp.run(shell, shell=True, check=True, cwd=extract_dir)
@@ -1172,14 +1171,6 @@ if "cgal" in targets:
os.environ["CC"] = MAC_CROSS_COMPILE_INTEL_CC
gmp_args.extend(MAC_CROSS_COMPILE_INTEL_AUTOCONF_HOST_ARGS)
# Fixes configure failing to find a working compiler under GCC 15's default -std=gnu23.
# Issue presumably will be resolved in any next gmp version, but currently the last one is 6.3.0.
# Patch is just applying fix from upstream meantion below:
# https://gmplib.org/list-archives/gmp-bugs/2025-February/005561.html
gmp_patches = ["./patches/gmp/001-fix-std23.patch"]
if GMP_VERSION != "6.3.0":
raise Exception(f"GMP_VERSION changed to {GMP_VERSION}, check whether {gmp_patches} is still needed.")
build_dependency(
name=f"gmp-{GMP_VERSION}",
mode="autoconf",
@@ -1187,7 +1178,6 @@ if "cgal" in targets:
pre_compile_subs=(
[("build/config.h", "HAVE_OBSTACK_VPRINTF 1", "HAVE_OBSTACK_VPRINTF 0")] if "wasm" in flags else []
),
patch=gmp_patches,
# Sometimes ftp.gnu.org is very slow, use ftpmirror.gnu.org as a workaround.
download_url="https://ftpmirror.gnu.org/gnu/gmp/",
download_name=f"gmp-{GMP_VERSION}.tar.bz2",
-1
View File
@@ -68,7 +68,6 @@ def unpack_dependencies(install_dir: Path) -> None:
if __name__ == "__main__":
action = None
if len(sys.argv) != 2 or (action := sys.argv[1].lower()) not in ("pack", "unpack"):
print(__doc__)
sys.exit(1)
-27
View File
@@ -1,27 +0,0 @@
Fixes configure failing to find a working compiler under GCC 15's default
-std=gnu23 (upstream fix: https://gmplib.org/repo/gmp/rev/8e7bb4ae7a18).
Upstream fix is patching `acinclude.m4`, but since in the release tarball
all macros are already expanded to `configure` script, so we're patching
all occurrences of that macro.
--- a/configure
+++ b/configure
@@ -6568,7 +6568,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
@@ -8187,7 +8187,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
+32
View File
@@ -0,0 +1,32 @@
http://git.dev.opencascade.org/gitweb/?p=occt.git;a=commitdiff;h=0ab4e621833f4eae945a3762c9a29ee12e2eec53#patch1
diff --git a/src/HLRBRep/HLRBRep_InternalAlgo.cxx b/src/HLRBRep/HLRBRep_InternalAlgo.cxx
index ca885ca..c13cb06 100644 (file)
--- a/src/HLRBRep/HLRBRep_InternalAlgo.cxx
+++ b/src/HLRBRep/HLRBRep_InternalAlgo.cxx
@@ -165,7 +165,7 @@ void HLRBRep_InternalAlgo::Update ()
SB.Bounds(v1,v2,e1,e2,f1,f2);
for (Standard_Integer e = e1; e <= e2; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
HLRAlgo::DecodeMinMax(ed.MinMax(), TheMin, TheMax);
if (FirstTime) {
FirstTime = Standard_False;
@@ -307,7 +307,7 @@ void HLRBRep_InternalAlgo::InitEdgeStatus ()
Standard_Integer nf = myDS->NbFaces();
for (Standard_Integer e = 1; e <= ne; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
if (ed.Selected()) ed.Status().ShowAll();
}
// for (Standard_Integer f = 1; f <= nf; f++) {
@@ -368,7 +368,7 @@ void HLRBRep_InternalAlgo::Select ()
Standard_Integer nf = myDS->NbFaces();
for (Standard_Integer e = 1; e <= ne; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
ed.Selected(Standard_True);
}
+22
View File
@@ -0,0 +1,22 @@
From a0deb4ce8b43cf3c8b8c0a4225c6be5296446dbd Mon Sep 17 00:00:00 2001
From: Adam Eri <adam.eri@blackmirror.media>
Date: Tue, 3 Sep 2019 23:30:20 +0200
Subject: [PATCH] Resolves compile error on macOS
Resolves "no member named 'isnan' in namespace 'std'" on macOS
---
GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp | 1 +
1 file changed, 1 insertion(+)
diff --git a/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp b/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
index 1f9a3eef..dd6f5c59 100644
--- a/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
+++ b/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
@@ -10,6 +10,7 @@
#include "GeneratedSaxParserUtils.h"
#include <math.h>
+#include <cmath>
#include <memory>
#include <string.h>
#include <limits>
+89 -40
View File
@@ -1,8 +1,13 @@
[project]
name = "IfcOpenShell"
version = "0.0.0"
# Don't provide requires-python explicitly
# allowing pyprojects to set their own (e.g. bonsai and general ifcopenshell version differ).
dependencies = [
"black==26.3.1",
"ruff==0.15.12",
"poethepoet",
"ty==0.0.32",
"gersemi==0.26.1",
]
[tool.black]
line-length = 120
@@ -38,7 +43,6 @@ exclude = [
# then they will be inherited by projects' .toml files.
# This allows using assuming different Python version for different projects.
[tool.ruff]
line-length = 120
exclude = [
# Submodules.
"src/ifcopenshell-python/ifcopenshell/express",
@@ -79,36 +83,92 @@ ignore = [
]
[tool.ty.rules]
all = "error"
all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
# Maybe later, requires to specify element types for all generics.
missing-type-argument = "ignore"
# Conflicts with `bpy` props defined using annotations.
invalid-type-form = "ignore"
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
cyclic-type-alias-definition = "error"
dataclass-field-order = "error"
duplicate-base = "error"
duplicate-kw-only = "error"
empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
ineffective-final = "error"
instance-layout-conflict = "error"
invalid-dataclass = "error"
invalid-dataclass-override = "error"
invalid-enum-member-annotation = "error"
invalid-explicit-override = "error"
invalid-frozen-dataclass-subclass = "error"
invalid-generic-class = "error"
invalid-generic-enum = "error"
invalid-ignore-comment = "error"
invalid-legacy-positional-parameter = "error"
invalid-legacy-type-variable = "error"
invalid-named-tuple = "error"
invalid-newtype = "error"
invalid-overload = "error"
invalid-paramspec = "error"
invalid-protocol = "error"
invalid-syntax-in-forward-annotation = "error"
invalid-total-ordering = "error"
invalid-type-alias-type = "error"
invalid-type-checking-constant = "error"
invalid-type-guard-definition = "error"
invalid-type-variable-bound = "error"
invalid-type-variable-constraints = "error"
invalid-typed-dict-header = "error"
invalid-typed-dict-statement = "error"
override-of-final-method = "error"
override-of-final-variable = "error"
possibly-missing-import = "error"
possibly-missing-submodule = "error"
# Has false positives due to ty walrus operator bug.
# possibly-unresolved-reference = "error"
raw-string-type-annotation = "error"
redundant-final-classvar = "error"
shadowed-type-variable = "error"
subclass-of-final-class = "error"
super-call-in-named-tuple-method = "error"
unavailable-implicit-super-arguments = "error"
unbound-type-variable = "error"
undefined-reveal = "error"
unresolved-global = "error"
unresolved-import = "error"
unresolved-reference = "error"
unused-ignore-comment = "error"
unused-type-ignore-comment = "error"
useless-overload-body = "error"
# Non-structural rules:
deprecated = "error"
zero-stepsize-in-slice = "error"
possibly-missing-implicit-call = "error"
unused-awaitable = "error"
# Function argument rules:
# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
call-non-callable = "ignore"
# bpy is missing some context manager implementations.
invalid-context-manager = "ignore"
# Doesn't go well with `bpy.ops.xxx.yyy`.
unresolved-attribute = "ignore"
# call-non-callable = "error"
conflicting-argument-forms = "error"
# Too many false positives.
invalid-argument-type = "ignore"
invalid-method-override = "ignore"
invalid-assignment = "ignore"
invalid-parameter-default = "ignore"
missing-override-decorator = "ignore"
invalid-yield = "ignore"
invalid-return-type = "ignore"
non-callable-init-subclass = "ignore"
not-iterable = "ignore"
possibly-missing-attribute = "ignore"
no-matching-overload = "ignore"
not-subscriptable = "ignore"
unsupported-dynamic-base = "ignore"
unsupported-operator = "ignore"
# invalid-argument-type = "error"
missing-argument = "error"
parameter-already-assigned = "error"
positional-only-parameter-as-kwarg = "error"
too-many-positional-arguments = "error"
unknown-argument = "error"
# Has a lot of warnings due to current ty walrus operator issues.
# index-out-of-bounds = "error"
# unresolved-attribute = "error"
[tool.ty.environment]
extra-paths = [
@@ -155,18 +215,6 @@ exclude = [
[tool.poe.tasks]
dev-setup.sequence = [
{cmd = "uv sync"},
{cmd = "uv pip install -e ./src/bsdd/"},
{cmd = "uv pip install -e ./src/ifcopenshell-python/[advanced,dev]"},
{cmd = "uv pip install -e ./src/ifcedit/"},
{cmd = "uv pip install -e ./src/ifcpatch/"},
{cmd = "uv pip install -e ./src/ifcquery/"},
{cmd = "uv pip install -e './src/ifcmcp/[mcp]'"},
{cmd = "uv pip install -r src/bonsai/requirements-dev.txt"},
]
dev-setup.help = "Install repo packages in editable mode"
ruff = "ruff check"
black = "black ."
@@ -192,9 +240,9 @@ format.sequence = ["black", "ruff"]
cmake-format = "gersemi . --in-place"
[tool.poe.tasks.ty-ios]
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
nix/
src/bcf
src/bsdd
src/ifc2ca
@@ -209,6 +257,7 @@ cmd = """
src/ifcpatch
src/ifctester
--python=src/ifcopenshell-python/.venv
--ignore unresolved-reference
"""
[tool.poe.tasks.bonsai-deps]
-5
View File
@@ -1,5 +0,0 @@
black==26.3.1
ruff==0.15.22
poethepoet
ty==0.0.61
gersemi==0.26.1
-6
View File
@@ -188,8 +188,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
@@ -208,8 +206,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
@@ -226,8 +222,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
+1 -1
View File
@@ -106,7 +106,7 @@ endif
endif # def PLATFORM
# Current build commit hash.
OLD:=3e7b739
OLD:=1c5b825
.PHONY: bump
bump:
ifndef NEW
-1
View File
@@ -90,7 +90,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,
+4 -22
View File
@@ -24,28 +24,10 @@ a text, a tspan { fill: blue !important; text-decoration: underline;}
a:hover { cursor: pointer; }
.cut { fill: black; stroke: black; stroke-linecap: 'round'; stroke-width: 0.35; fill-rule: evenodd; }
.projection { fill: white; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
/* SVG edge classification (issue #3668): see edge-classification.md. These select directly on
the <path> element (each classified projection edge carries its own class), so they win over
the inherited .projection rule above regardless of specificity. */
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
path.boundary { stroke: black; stroke-width: 0.3; stroke-opacity: 0.9; }
path.crease { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; }
path.sharp { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; }
path.flush { stroke: black; stroke-width: 0.1; stroke-opacity: 0.4; }
/* Debug CSS for troubleshooting edge classification */
/*
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
path.boundary { stroke: orange; stroke-width: 0.3; stroke-opacity: 0.9; }
path.crease { stroke: green; stroke-width: 0.25; stroke-opacity: 0.85; }
path.sharp { stroke: red; stroke-width: 0.18; stroke-opacity: 0.7; }
path.flush { stroke: blue; stroke-width: 0.1; stroke-opacity: 0.4; }
*/
.surface {fill: white; stroke-width: 0.1;}
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
/* .IfcGeographicElement { fill: none; stroke: rgb(150, 150, 150); stroke-linecap: 'round'; stroke-dasharray: 1, 2;} */
.surface { stroke: none; fill: #fff; fill-rule: evenodd; }
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
.IfcGeographicElement { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 1; }
.PredefinedType-LINEWORK { stroke: black; stroke-width: 0.25; }
.PredefinedType-LINEWORK.dashed { stroke-dasharray: 3, 2; }
.PredefinedType-LINEWORK.fine { stroke-width: 0.18; stroke: #777777; }
@@ -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,#31,#32,#33,#34,#35,#36,#37));
#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,14 +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.);
#31=IFCSIMPLEPROPERTYTEMPLATE('1cFVJnqT13m8ItkMHaI1tp',$,'UseEdgeClassification','Enable the boundary/outline/sharp/crease/flush SVG edge classification scheme (issue #3668). When false, drawings use the original unclassified linework.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#32=IFCSIMPLEPROPERTYTEMPLATE('2kB$mxBgnBUvhjh0Ti0c4P',$,'RenderCreases','Whether to render ''crease'' (concave) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('3MSIJNW$T8r9Hl12kk0BY$',$,'ValleyAngleMinDegrees','Minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as ''crease'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#37=IFCSIMPLEPROPERTYTEMPLATE('1zM9sia2L8RQDnWZxgUwlZ',$,'JoinClasses','Comma separated list of IFC classes whose cut linework will be joined together when they meet (e.g. mitred at a corner).\X2\000A\X0\Defaults to ''IfcWall,IfcSlab'' if not set. Override to also join other classes, such as ''IfcWall,IfcSlab,IfcCovering''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
@@ -244,7 +244,7 @@ function addGanttElement(blenderId, tasks, workSched, filename) {
vShowTaskInfoLink: 1, // Show link in tool tip (0/1)
vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily
vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data.
vFormatArr: ["Hour", "Day", "Week", "Month", "Quarter"], // vUseSingleCell keeps Hour usable on large charts.
vFormatArr: ["Day", "Week", "Month", "Quarter"], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers,
vShowRes: true, // Disable the resource column.
vShowComp: false, // Disable the completion column.
vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want.
-9
View File
@@ -51,11 +51,9 @@ from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
DoorSwingReadonlyDecorator,
MEPSegmentExtendPreviewDecorator,
MEPSystemPathDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
WallSystemPathDecorator,
)
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator
@@ -320,11 +318,9 @@ def loadIfcStore(scene: bpy.types.Scene) -> None:
IfcStore.purge()
refresh_ui_data()
if not tool.Ifc.get():
tool.Autosave.cancel_timer()
return
tool.Ifc.schema()
IfcStore.relink_all_objects()
tool.Autosave.reset_timer()
@persistent
@@ -517,8 +513,6 @@ def _install_viewport_overlays() -> None:
NestDecorator.uninstall()
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
MEPSystemPathDecorator.uninstall()
WallSystemPathDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall()
@@ -538,9 +532,6 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_paths:
MEPSystemPathDecorator.install(bpy.context)
WallSystemPathDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
+1 -1
View File
@@ -46,7 +46,7 @@ IFC_CONNECTED_TYPE = Union[bpy.types.Material, bpy.types.Object]
class OperationData(TypedDict):
id: int
guid: NotRequired[str]
obj: NotRequired[str]
obj: str
class EditObjectOperationData(TypedDict):
+2 -20
View File
@@ -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] = {}
@@ -980,13 +979,8 @@ class IfcImporter:
if unit.Name == "METRE":
if not unit.Prefix:
bpy.context.scene.unit_settings.length_unit = "METERS"
elif f"{unit.Prefix}METERS" in ("KILOMETERS", "CENTIMETERS", "MILLIMETERS", "MICROMETERS"):
bpy.context.scene.unit_settings.length_unit = f"{unit.Prefix}METERS"
else:
# Blender's length_unit enum has no entry for other
# SI prefixes (e.g. DECIMETERS), so fall back to
# adaptive display instead of failing to open.
bpy.context.scene.unit_settings.length_unit = "ADAPTIVE"
bpy.context.scene.unit_settings.length_unit = f"{unit.Prefix}METERS"
else:
bpy.context.scene.unit_settings.system = "IMPERIAL"
name = unit.Name.lower()
@@ -1103,14 +1097,12 @@ class IfcImporter:
vertices = [[v[i], v[i + 1], v[i + 2], 1] for i in range(0, len(v), 3)]
edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)]
v2 = None
polyline = None
for edge in edges:
v1 = vertices[edge[0]]
if v1 != v2:
polyline = curve.splines.new("POLY")
polyline.points[-1].co = mathutils.Vector(v1)
v2 = vertices[edge[1]]
assert polyline is not None
polyline.points.add(1)
polyline.points[-1].co = mathutils.Vector(v2)
edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(geometry).tolist()
@@ -1228,17 +1220,7 @@ class IfcImporter:
continue
for i in range(len(data)):
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
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.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":
@@ -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)
@@ -843,6 +843,7 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, opening)
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
mat.translation = (0, 0, 0)
opening_bm = bmesh.new()
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
for vert in verts:
@@ -1059,7 +1060,6 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
return tool.Ifc.get().createIfcConnectionSurfaceGeometry(surface)
def export_surface(self, polygon, target_face_matrix):
ifc_file = tool.Ifc.get()
x_axis = target_face_matrix.col[0][:3]
z_axis = target_face_matrix.col[2][:3]
p1 = target_face_matrix.translation
@@ -1072,20 +1072,18 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
placement = builder.create_axis2_placement_3d([o / self.unit_scale for o in p1], z_axis, x_axis)
surface.BasisSurface = tool.Ifc.get().create_entity("IfcPlane", placement)
schema = ifc_file.schema
if schema != "IFC2X3":
if tool.Ifc.get().schema != "IFC2X3":
points = [tool.Model.convert_si_to_unit(list(co)) for co in polygon.exterior.coords]
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
outer_boundary = tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False)
inner_boundaries: list[ifcopenshell.entity_instance] = []
inner_boundaries = []
for interior in polygon.interiors:
points = [tool.Model.convert_si_to_unit(list(co)) for co in interior.coords]
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
inner_boundaries.append(tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False))
else:
# TODO:
raise NotImplementedError(schema)
pass # TODO
surface.OuterBoundary = outer_boundary
surface.InnerBoundaries = inner_boundaries
+6 -3
View File
@@ -156,13 +156,16 @@ class BrickschemaReferencesData:
for rel in getattr(tool.Ifc.get_entity(bpy.context.active_object), "HasAssociations", []):
if rel.is_a("IfcRelAssociatesLibrary"):
reference = rel.RelatingLibrary
identification = tool.Document.get_external_reference_id(reference)
if not identification or "#" not in identification:
if tool.Ifc.get_schema() == "IFC2X3" and "#" not in reference.ItemReference:
continue
if tool.Ifc.get_schema() != "IFC2X3" and "#" not in reference.Identification:
continue
results.append(
{
"id": reference.id(),
"identification": identification,
"identification": (
reference.ItemReference if tool.Ifc.get_schema() == "IFC2X3" else reference.Identification
),
"name": reference.Name or "Unnamed",
}
)
@@ -400,7 +400,6 @@ class CadOffset(bpy.types.Operator):
[verts.update(e.verts) for e in edges]
# Use the viewport angle to determine the offset direction
wp = None
for area in bpy.context.screen.areas:
if area.type == "VIEW_3D":
# Don't ask me, I don't know.
@@ -410,7 +409,6 @@ class CadOffset(bpy.types.Operator):
z = area.spaces.active.region_3d.view_rotation @ Vector((0, 0, 1))
wp = Matrix([x, y, z, Vector((0, 0, 0))]).to_4x4().transposed()
break
assert wp is not None
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
@@ -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()
@@ -478,7 +478,6 @@ class ChangeClassificationLevel(bpy.types.Operator):
def execute(self, context):
props = tool.Classification.get_classification_props()
props.available_library_references.clear()
reference = None
for reference in IfcStore.classification_file.by_id(self.parent_id).HasReferences:
new = props.available_library_references.add()
new.identification = reference.Identification or ""
@@ -486,7 +485,6 @@ class ChangeClassificationLevel(bpy.types.Operator):
new.ifc_definition_id = reference.id()
new.has_references = bool(reference.HasReferences)
new.referenced_source
assert reference
if reference.ReferencedSource.is_a("IfcClassificationReference"):
props.active_library_referenced_source = reference.ReferencedSource.ReferencedSource.id()
else:
@@ -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
-145
View File
@@ -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")
@@ -156,8 +156,6 @@ class CostSchedulesData:
values = root_element.CostValues
elif root_element.is_a("IfcConstructionResource"):
values = root_element.BaseCosts
else:
assert False, root_element
for cost_value in values or []:
cls._load_cost_value(root_element, data, cost_value)
# data["CostValues"].append(cost_value.id())
+22 -11
View File
@@ -127,25 +127,36 @@ class ObjectDocumentData:
identification = None
if is_information:
identification = tool.Document.get_document_information_id(relating_document)
if tool.Ifc.get_schema() == "IFC2X3":
identification = relating_document.DocumentId
else:
identification = relating_document.Identification
location = getattr(relating_document, "Location", None)
description = getattr(relating_document, "Description", "No description")
else:
description = relating_document.Description
referenced_document = tool.Document.get_reference_document(relating_document)
if tool.Ifc.get_schema() == "IFC2X3":
reference_to_document = relating_document.ReferenceToDocument
if not name and reference_to_document:
name = reference_to_document[0].Name
if not name and referenced_document:
name = referenced_document.Name
identification = relating_document.ItemReference
if not identification and reference_to_document:
identification = reference_to_document[0].DocumentId
location = relating_document.Location
else:
referenced_document = relating_document.ReferencedDocument
if not name and referenced_document:
name = referenced_document.Name
identification = tool.Document.get_external_reference_id(relating_document)
if not identification and referenced_document:
identification = tool.Document.get_document_information_id(referenced_document)
identification = relating_document.Identification
if not identification and referenced_document:
identification = referenced_document.Identification
location = relating_document.Location
# IFC2X3 IfcDocumentInformation has no Location to fall back to.
if location is None and referenced_document and tool.Ifc.get_schema() != "IFC2X3":
location = referenced_document.Location
location = relating_document.Location
if location is None and referenced_document:
location = referenced_document.Location
location = cls.convert_to_file_uri(location) if location else None
@@ -47,7 +47,6 @@ classes = (
operator.CleanWireframes,
operator.ContractSheet,
operator.ConvertSVGToDXF,
operator.CopyAnnotationToDrawing,
operator.CopyTextToSelection,
operator.CreateDrawing,
operator.CreateSheets,
@@ -425,12 +425,10 @@ class BaseDecorator:
blf.size(font_id, font_size_px)
w, h = None, None
if box_alignment or center or vcenter:
w, h = blf.dimensions(font_id, text)
if box_alignment:
assert w is not None and h is not None
box_alignment_offset = Vector((0, 0))
if "bottom" in box_alignment:
pass
@@ -452,12 +450,10 @@ class BaseDecorator:
else:
# horizontal centering
if center:
assert w is not None
pos -= Vector((cos, sin)) * w * 0.5
# vertical centering
if vcenter:
assert h is not None
pos -= Vector((-sin, cos)) * h * 0.5
# side-shifting
@@ -1005,8 +1001,6 @@ class FallDecorator(BaseDecorator):
O = A.copy()
O.z = B.z
run = (B - O).length
angle_tg = None
if run != 0:
angle_tg = rise / run
angle = round(degrees(atan(angle_tg)))
@@ -1024,7 +1018,6 @@ class FallDecorator(BaseDecorator):
elif object_type == "SLOPE_PERCENT":
if angle == 90:
return "-"
assert angle_tg is not None
return f"{round(angle_tg * 100)} %"
return "NO DATA"
@@ -1256,7 +1249,6 @@ class SectionLevelDecorator(BaseDecorator):
}
# process edges
text_position, text_dir = None, None
for edge in edges_original:
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
start_i = len(output_verts)
@@ -1562,39 +1554,32 @@ class SectionDecorator(BaseDecorator):
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
start_i = len(output_verts)
circle_head = None
if display_start_circle or display_end_circle:
circle_head = get_circle_head(circle_size)
triangle_head, divider_offset, edge_dir_circle = None, None, None
display_symbol = display_start_symbol or display_end_symbol
if display_symbol or connect_markers:
if display_start_symbol or display_end_symbol or connect_markers:
edge_dir = (v1 - v0).normalized()
side = (edge_dir.yx * Vector((1, -1))).to_3d()
edge_dir_circle = edge_dir * circle_size
if display_symbol:
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
divider_offset = []
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
divider_offset.append(edge_dir_circle)
if display_start_symbol or display_end_symbol:
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
divider_offset = []
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
divider_offset.append(edge_dir_circle)
if display_start_circle:
assert circle_head is not None
start_i = add_verts_sequence([v + v0 for v in circle_head], start_i, **out_kwargs, closed=True)
# circle middle divider
if not display_start_symbol:
assert divider_offset is not None
start_i = add_verts_sequence(
[v0 + divider_offset[0], v0 - divider_offset[1]], start_i, **out_kwargs
)
if display_start_symbol:
assert triangle_head is not None
start_i = add_verts_sequence([v + v0 for v in triangle_head], start_i, **out_kwargs, closed=True)
if display_end_circle:
assert circle_head is not None
start_i = add_verts_sequence([v + v1 for v in circle_head], start_i, **out_kwargs, closed=True)
# circle middle divider
if not display_end_symbol:
@@ -1603,11 +1588,9 @@ class SectionDecorator(BaseDecorator):
)
if display_end_symbol:
assert triangle_head is not None
start_i = add_verts_sequence([v + v1 for v in triangle_head], start_i, **out_kwargs, closed=True)
if connect_markers:
assert edge_dir_circle is not None
gap = []
gap.append(edge_dir_circle if display_start_symbol else Vector((0, 0, 0)))
gap.append(edge_dir_circle if display_end_symbol else Vector((0, 0, 0)))
@@ -1694,12 +1677,6 @@ class CutDecorator:
selected_elements_color = self.addon_prefs.decorator_color_selected
self.fallback_colour = (0.3, 0.3, 0.3, 1)
# Evaluate camera movement once per redraw rather than twice per object: is_camera_moved()
# runs eval()/numpy on the camera matrix and, as a side effect, refreshes the stored
# checksum on the first True result - so calling it per object also made the second call
# (fill) see an already-updated checksum and skip recalculating when it shouldn't.
self.camera_moved = self.is_camera_moved()
all_vertices = []
all_edges = []
selected_vertices = []
@@ -1825,35 +1802,23 @@ class CutDecorator:
# Currently selected objects must be recalculated as they may be being moved / edited.
# If the camera is selected, we also recalculate as the user may be moving the camera.
is_selected = obj.select_get()
recalc_cut = not has_cut_cache or is_selected or self.camera_moved
recalc_fill = not has_fill_cache or is_selected or self.camera_moved
if not (recalc_cut or recalc_fill):
return
# The intersection test builds a bmesh and scans every vertex; both recalculations need
# the same answer, so compute it once here rather than once in each.
is_intersecting = tool.Drawing.is_intersecting_camera(obj, context.scene.camera)
if recalc_cut:
self.recalculate_cut(context, obj, element, is_intersecting)
if recalc_fill:
self.recalculate_fill(context, obj, element, is_intersecting)
if not has_cut_cache or obj.select_get() or self.is_camera_moved():
self.recalculate_cut(context, obj, element)
if not has_fill_cache or obj.select_get() or self.is_camera_moved():
self.recalculate_fill(context, obj, element)
def recalculate_cut(
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
) -> None:
if is_intersecting:
def recalculate_cut(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
if tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera)
DecoratorData.cut_cache[element.id()] = (verts, edges)
else:
DecoratorData.cut_cache[element.id()] = (False, False)
def recalculate_fill(
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
) -> None:
def recalculate_fill(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
element_id = element.id()
if not is_intersecting:
if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
DecoratorData.fill_cache[element_id] = {}
return
@@ -1906,8 +1871,6 @@ class CutDecorator:
layer_set = material
offset = 0
sense_factor = 1
else:
assert False, material
if len(layer_set.MaterialLayers) == 1:
material = layer_set.MaterialLayers[0].Material
@@ -1934,8 +1897,6 @@ class CutDecorator:
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, usage
no *= sense_factor
last_i = len(layer_set.MaterialLayers) - 1
+42 -56
View File
@@ -82,15 +82,7 @@ import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Literal,
Optional,
Protocol,
runtime_checkable,
)
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
import blf
import bpy
@@ -113,9 +105,6 @@ from mathutils.kdtree import KDTree
import bonsai.tool as tool
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
if TYPE_CHECKING:
import bmesh
SNAP_POINT_SIZE = 10.0
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
SNAP_MAX_RADIUS = 50.0
@@ -170,13 +159,40 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
def _is_transform_modal_active(context) -> bool:
"""Module-local alias for ``tool.Blender.is_transform_modal_active``.
_BONSAI_TRANSFORM_MACROS = frozenset(
{
# Bonsai overrides Blender's default move/duplicate keymaps with
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
# modal entry, the inner TRANSFORM_OT_translate does not surface in
# window.modal_operators — the macro's own idname does. The
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
# runtime (the class declaration uses the dotted ``bim.`` form).
"BIM_OT_override_move_macro", # G key
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
}
)
Preserved as a name so AST scans and call sites in this file stay
decoupled from the helper's home module.
"""
return tool.Blender.is_transform_modal_active(context)
def _is_transform_modal_active(context) -> bool:
"""True iff a Blender transform modal (G/R/S and siblings, including
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
updates. Reads ``window.modal_operators`` the Blender 4.2+ collection of
running modal operators. Parametric gizmo groups gate poll + draw_prepare
on this so they hide for the duration of the drag instead of sliding
off-cursor as the matrix updates each frame."""
window = getattr(context, "window", None)
if window is None:
return False
modal_ops = getattr(window, "modal_operators", None)
if not modal_ops:
return False
for op in modal_ops:
idname = op.bl_idname
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
return True
return False
def _hide_all_non_modal_gizmos(group) -> None:
@@ -2046,9 +2062,7 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
def setup(self) -> None:
super().setup()
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
from bonsai.bim.module.drawing import gizmo_textures
self._quad_batch = batch_for_shader(
gizmo_textures.get_shader(),
@@ -2057,9 +2071,7 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
)
def draw(self, context: bpy.types.Context) -> None:
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
from bonsai.bim.module.drawing import gizmo_textures
texture = gizmo_textures.get_icon_texture(self.icon_name)
if texture is None:
@@ -4865,14 +4877,7 @@ class GizmoDimension(GizmoMovable):
self.init_value = click_distance
# Schematic gizmos opt out of dimension snap. Force the header
# indicator to ``off`` for the drag's duration so the user sees the
# state matches behaviour; ``exit`` restores ``initial_snap_state``.
# Skipping the snap cache here also avoids the per-drag mesh probe.
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
if not snap_supported:
context.scene.tool_settings.use_snap = False
elif self.initial_snap_state and self.active_obj:
if self.initial_snap_state and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
@@ -4914,18 +4919,11 @@ class GizmoDimension(GizmoMovable):
if not region or not rv3d:
return {"RUNNING_MODAL"}
# Group-level opt-out: schematic gizmos float in viewport space, so
# global-snap-to-scene-vertices would produce spurious value jumps.
# The fallback (``True``) covers any gizmo whose group is not a
# ``BaseParametricGizmoGroup``.
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
if snap_supported:
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
current_coord = (event.mouse_region_x, event.mouse_region_y)
@@ -4949,7 +4947,7 @@ class GizmoDimension(GizmoMovable):
delta = (current_3d - self.start_location).dot(axis_direction)
if snap_supported and tool_settings.use_snap and self.active_obj:
if tool_settings.use_snap and self.active_obj:
# Snap the dimension tip (not mouse position) to target
# Calculate where the dimension tip would be with current delta
# The tip is at: gizmo_origin + axis * (init_value + delta)
@@ -5322,13 +5320,6 @@ class BaseParametricGizmoGroup:
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
# Default: dimension drags respect Blender's global snap (Ctrl-toggleable
# during drag). Subclasses whose dimensions float in viewport space rather
# than aligning to real-world geometry should override to ``False`` —
# snapping to scene vertices in that case produces spurious value jumps
# as the mouse crosses unrelated meshes.
snap_enabled_on_dimensions: bool = True
# === Icon Gizmo Layout (meters) ===
# Icons are positioned in a horizontal row above the element:
# [Validate] [Cancel] [Cycle]
@@ -6589,11 +6580,6 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
# list and become no-ops. The schematic equivalents below take their place.
dimension_gizmo_props: list[DimensionGizmoConfig] = []
# Schematic dimensions float in billboarded viewport space, not aligned to
# real-world geometry. Snapping the dragged tip to scene vertices would
# produce nonsensical value jumps as the mouse crosses unrelated meshes.
snap_enabled_on_dimensions: bool = False
# Declarative dimension configuration consumed by ``setup_schematic_dimensions``
# and ``update_schematic_dimensions``. Each config produces one
# ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local
@@ -50,9 +50,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(
+1 -12
View File
@@ -189,20 +189,14 @@ def format_distance(
if hasattr(length_unit, "Prefix") and length_unit.Prefix:
unit_length = length_unit.Prefix + length_unit.Name
unit_length_mapping = {
"MILE": "MILES",
"FOOT": "FEET",
"INCH": "INCHES",
"KILOMETRE": "KILOMETERS",
"METRE": "METERS",
"DECIMETRE": "DECIMETERS",
"CENTIMETRE": "CENTIMETERS",
"MILLIMETRE": "MILLIMETERS",
"MICROMETRE": "MICROMETERS",
}
# Fall through for units without a dedicated formatter (e.g.
# HECTOMETRE) so they use the adaptive branch instead of a
# KeyError (#8255).
unit_length = unit_length_mapping.get(unit_length, unit_length)
unit_length = unit_length_mapping[unit_length]
# For now we only format area in IFC Units
if area_unit := ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "AREAUNIT"):
area_unit_symbol = " " + ifcopenshell.util.unit.get_unit_symbol(area_unit)
@@ -225,11 +219,9 @@ def format_distance(
unit_system, unit_length, unit_fraction = unit_mapping[custom_unit]
value *= unit_scale
tx_dist = None
# Imperial Formatting
if unit_system == "IMPERIAL":
toInches = None
if in_unit_length:
if unit_length == "INCHES":
toInches = 1
@@ -243,7 +235,6 @@ def format_distance(
toInches = 1550
inPerFoot = 144
assert toInches is not None
decInches = value * toInches
decFeet = decInches / 12
@@ -386,7 +377,6 @@ def format_distance(
if precision and isinstance(precision, float):
value = precision * round(float(value) / precision)
fmt = None
if decimal_places is not None:
fmt = "%1." + str(decimal_places) + "f"
@@ -469,7 +459,6 @@ def format_distance(
assert f"Unexpected unit_system - '{unit_system}'."
# tx_dist = fmt % value
assert tx_dist is not None
return tx_dist
+24 -220
View File
@@ -57,7 +57,7 @@ import shapely
from bpy_extras.image_utils import load_image
from bpy_extras.io_utils import ImportHelper
from lxml import etree
from mathutils import Color, Matrix, Vector
from mathutils import Color, Vector
import bonsai.bim.export_ifc
import bonsai.bim.handler
@@ -228,90 +228,6 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator):
)
def get_copy_annotation_target_drawings(self, context):
global COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"]
drawings.sort(key=lambda d: d.Name or "")
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [(str(d.id()), d.Name or "Unnamed", "") for d in drawings]
return COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = []
class CopyAnnotationToDrawing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.copy_annotation_to_drawing"
bl_label = "Copy Annotation To Drawing"
bl_description = (
"Copy the selected annotations to another drawing.\n\n"
"The copies become independent annotations assigned to the chosen drawing, "
"placed in its view plane. The originals stay in their current drawing"
)
bl_options = {"REGISTER", "UNDO"}
target_drawing: bpy.props.EnumProperty(name="Target Drawing", items=get_copy_annotation_target_drawings)
if TYPE_CHECKING:
target_drawing: str
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC project loaded.")
return False
if not cls.get_selected_annotations(context):
cls.poll_message_set("No annotation selected.")
return False
return True
@classmethod
def get_selected_annotations(cls, context) -> list[ifcopenshell.entity_instance]:
return [
element
for obj in context.selected_objects
if (element := tool.Ifc.get_entity(obj))
and element.is_a("IfcAnnotation")
and element.ObjectType != "DRAWING"
]
def invoke(self, context, event):
assert context.window_manager
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
row = self.layout.row()
row.prop(self, "target_drawing")
def _execute(self, context):
if not self.target_drawing:
self.report({"ERROR"}, "No target drawing selected.")
return {"CANCELLED"}
target_drawing = tool.Ifc.get().by_id(int(self.target_drawing))
annotations = self.get_selected_annotations(context)
previous_selection = [obj for a in annotations if (obj := tool.Ifc.get_object(a))]
previous_active = context.view_layer.objects.active
copied = core.copy_annotations_to_drawing(
tool.Ifc,
tool.Collector,
tool.Drawing,
tool.Geometry,
annotations=annotations,
target_drawing=target_drawing,
)
for obj in context.selected_objects:
obj.select_set(False)
for obj in previous_selection:
if obj.name in context.view_layer.objects:
obj.select_set(True)
if previous_active and previous_active.name in context.view_layer.objects:
context.view_layer.objects.active = previous_active
skipped = len(annotations) - len(copied)
message = f"Copied {len(copied)} annotations to {target_drawing.Name or 'Unnamed'}."
if skipped:
message += f" Skipped {skipped} already in that drawing."
self.report({"INFO"}, message)
class CreateDrawing(bpy.types.Operator):
"""Creates/refreshes a .svg drawing
@@ -686,7 +602,6 @@ class CreateDrawing(bpy.types.Operator):
context_type: Literal["body", "annotation"],
drawing_elements: set[ifcopenshell.entity_instance],
target_view: str,
link_matrix: Optional[Matrix] = None,
) -> None:
drawing_elements = drawing_elements.copy()
contexts_: list[list[int]] = getattr(contexts, context_type)
@@ -698,19 +613,9 @@ class CreateDrawing(bpy.types.Operator):
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
if link_matrix is not None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
t = link_matrix.to_translation()
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
geom_settings.set("model-offset", offset)
q = link_matrix.to_quaternion()
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
elif z_offset:
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
# A 2mm Z offset to combat Z-fighting in plan or RCPs
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
geom_settings.set("model-offset", (0.0, 0.0, 0.002 if target_view == "PLAN_VIEW" else -0.002))
geom_settings.set("context-ids", context)
it = ifcopenshell.geom.iterator(
@@ -782,8 +687,6 @@ class CreateDrawing(bpy.types.Operator):
layer_set = material
offset = 0
sense_factor = 1
else:
assert False, material
camera_matrix_i = context.scene.camera.matrix_world.inverted()
@@ -808,6 +711,7 @@ class CreateDrawing(bpy.types.Operator):
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001)
bmesh.ops.triangle_fill(bm, use_dissolve=True, edges=bm.edges)
prev_co = None
if not usage:
sense_factor = 1 # Assume the extrusion vector points in the direction sense
no = tool.Drawing.get_extrusion_vector(element).normalized()
@@ -824,8 +728,6 @@ class CreateDrawing(bpy.types.Operator):
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, usage
no *= sense_factor
last_i = len(layer_set.MaterialLayers) - 1
for i, layer in enumerate(layer_set.MaterialLayers):
@@ -993,10 +895,6 @@ class CreateDrawing(bpy.types.Operator):
if os.path.isfile(svg_path) and self.props.should_use_linework_cache:
return svg_path
ifc = tool.Ifc.get()
semantics = None
pairs = None
# in case of printing multiple drawings we need to sync just once
if self.sync and self.drawing_index == 0:
with profile("sync"):
@@ -1025,16 +923,11 @@ class CreateDrawing(bpy.types.Operator):
bim_props = tool.Blender.get_bim_props()
prefs = tool.Blender.get_addon_preferences()
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
files = {bim_props.ifc_file: tool.Ifc.get()}
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
try:
link_matrix = tool.Project.calculate_link_matrix(link)
except Exception:
link_matrix = None
files[link.filepath] = (self.get_linked_file(link), link_matrix)
files[link.filepath] = self.get_linked_file(link)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -1042,13 +935,7 @@ class CreateDrawing(bpy.types.Operator):
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
# Accumulated across every file in the loop below (main model plus any
# linked models) so the SHAPELY fill pass after the loop covers all of
# them, not just whichever file happened to be processed last.
raycast_objs = set()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) in files.items():
for ifc_path, ifc in files.items():
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
@@ -1057,24 +944,13 @@ class CreateDrawing(bpy.types.Operator):
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
@@ -1141,6 +1017,16 @@ class CreateDrawing(bpy.types.Operator):
# shapely variant
group = root.find("{http://www.w3.org/2000/svg}g")
raycast_objs = set()
elements_with_faces = set()
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
@@ -1400,18 +1286,6 @@ class CreateDrawing(bpy.types.Operator):
self.svg_settings = ifcopenshell.geom.settings()
self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
# SVG edge classification (issue #3668). See edge-classification.md. Settings are
# per-drawing, stored in EPset_Drawing and read into self.cprops by import_camera_props.
try:
self.svg_settings.set("svg-use-edge-classification", self.cprops.use_edge_classification)
self.svg_settings.set("svg-render-crease-edges", self.cprops.render_creases)
self.svg_settings.set("svg-valley-angle-min-degrees", self.cprops.valley_angle_min_degrees)
self.svg_settings.set("svg-render-sharp-edges", self.cprops.render_sharp)
self.svg_settings.set("svg-ridge-angle-min-degrees", self.cprops.ridge_angle_min_degrees)
self.svg_settings.set("svg-emit-flush-edges", self.cprops.render_flush)
except Exception:
# Backwards compatibility with older ifcopenshell builds that don't expose these keys.
pass
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
self.serialiser = ifcopenshell.geom.serializers.svg(
@@ -1555,15 +1429,6 @@ class CreateDrawing(bpy.types.Operator):
"Material.Name",
]
join_classes = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinClasses")
if join_classes:
join_classes = tuple(c.strip() for c in join_classes.split(",") if c.strip())
else:
# Architectural convention only merges these objects by default. E.g. pipe
# segments and fittings shouldn't merge. Users may override this per-drawing
# via the EPset_Drawing.JoinClasses property (e.g. to also join IfcCovering).
join_classes = ("IfcWall", "IfcSlab")
group = root.find("{http://www.w3.org/2000/svg}g")
joined_paths = {}
self.is_manifold_cache = {}
@@ -1665,7 +1530,8 @@ class CreateDrawing(bpy.types.Operator):
)
path.attrib["d"] = d
if not any(element.is_a(c) for c in join_classes):
# Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge.
if not element.is_a("IfcWall") and not element.is_a("IfcSlab"):
continue
keys = []
@@ -1817,12 +1683,6 @@ class CreateDrawing(bpy.types.Operator):
key=lambda a: (
tool.Drawing.get_annotation_z_index(a),
1 if ifcopenshell.util.element.get_predefined_type(a) == "TEXT" else 0,
# Deterministic tiebreaker so equal-priority annotations keep a
# stable order across sessions. Without it the order comes from
# the set union above, which depends on entity hashes (and thus
# the file pointer), shuffling annotations between Blender
# restarts. See #6608.
a.id(),
),
)
@@ -2459,10 +2319,7 @@ class ActivateDrawingBase(tool.Ifc.Operator):
bl_description = (
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
drawing: bpy.props.IntProperty()
@@ -2478,32 +2335,13 @@ class ActivateDrawingBase(tool.Ifc.Operator):
default=False,
options={"SKIP_SAVE"},
)
load_selected_annotations: bpy.props.BoolProperty(
name="Load Selected Annotations",
description="Load the annotations of all selected drawings without switching the active view.",
default=False,
options={"SKIP_SAVE"},
)
include_annotations_in_selection: bpy.props.BoolProperty(
name="Include Annotations In Selection",
description="Also select the loaded annotation objects, not just the drawing cameras.",
default=False,
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
drawing: int
should_view_from_camera: bool
use_quick_preview: bool
load_selected_annotations: bool
include_annotations_in_selection: bool
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
self.load_selected_annotations = True
if event.alt:
self.include_annotations_in_selection = True
return self.execute(context)
if event.type == "LEFTMOUSE" and event.alt:
self.should_view_from_camera = False
if event.type == "LEFTMOUSE" and event.shift:
@@ -2516,37 +2354,6 @@ class ActivateDrawingBase(tool.Ifc.Operator):
if props.is_editing_drawings == False:
bpy.ops.bim.load_drawings()
if self.load_selected_annotations:
objs_to_select = []
active_camera = None
for d in props.drawings:
if not (d.is_drawing and d.is_selected):
continue
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
# Importing the camera (if missing) ensures the drawing's
# collection exists so the annotations get collected into it.
if not (camera := tool.Ifc.get_object(selected_drawing)):
camera = tool.Drawing.import_drawing(selected_drawing)
group = tool.Drawing.get_drawing_group(selected_drawing)
tool.Drawing.import_annotations_in_group(group)
if active_camera is None:
active_camera = camera
objs_to_select.append(camera)
if self.include_annotations_in_selection:
for element in tool.Drawing.get_group_elements(group) or []:
if element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING":
if annotation_obj := tool.Ifc.get_object(element):
objs_to_select.append(annotation_obj)
# Select the checked drawings' objects, with the first drawing's camera as active.
bpy.ops.object.select_all(action="DESELECT")
for obj in objs_to_select:
obj.select_set(True)
if active_camera is not None:
context.view_layer.objects.active = active_camera
return {"FINISHED"}
drawing = tool.Ifc.get().by_id(self.drawing)
dprops = tool.Drawing.get_document_props()
@@ -2632,10 +2439,7 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
bl_description = (
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
@@ -3697,7 +3501,7 @@ class EditSheet(bpy.types.Operator, tool.Ifc.Operator):
if sheet.is_a("IfcDocumentInformation"):
self.document_type = "SHEET"
self.name = sheet.Name
self.identification = tool.Document.get_document_information_id(sheet)
self.identification = sheet.DocumentId if tool.Ifc.get_schema() == "IFC2X3" else sheet.Identification
elif sheet.is_a("IfcDocumentReference") and tool.Drawing.get_reference_description(sheet) == "TITLEBLOCK":
self.document_type = "TITLEBLOCK"
else:
@@ -536,50 +536,6 @@ class BIMCameraProperties(PropertyGroup):
default=True,
update=get_update_layer_callback("has_annotation", "HasAnnotation"),
)
use_edge_classification: BoolProperty(
name="Use Edge Classification",
description="Classify projection edges into boundary/outline/sharp/crease/flush "
"instead of drawing all linework identically. See edge-classification.md",
default=False,
update=get_update_layer_callback("use_edge_classification", "UseEdgeClassification"),
)
render_creases: BoolProperty(
name="Render Creases",
description="Render 'crease' (concave) projection edges",
default=True,
update=get_update_layer_callback("render_creases", "RenderCreases"),
)
valley_angle_min_degrees: FloatProperty(
name="Valley Angle Minimum",
description="Minimum concave dihedral deviation from flat, in degrees, for a projection "
"edge to be classified as 'crease' rather than 'flush'",
default=12.0,
min=0.0,
max=180.0,
update=get_update_layer_callback("valley_angle_min_degrees", "ValleyAngleMinDegrees"),
)
render_sharp: BoolProperty(
name="Render Sharp",
description="Render 'sharp' (convex) projection edges",
default=True,
update=get_update_layer_callback("render_sharp", "RenderSharp"),
)
ridge_angle_min_degrees: FloatProperty(
name="Ridge Angle Minimum",
description="Minimum convex dihedral deviation from flat, in degrees, for a projection "
"edge to be classified as 'sharp' rather than 'flush'",
default=45.0,
min=0.0,
max=180.0,
update=get_update_layer_callback("ridge_angle_min_degrees", "RidgeAngleMinDegrees"),
)
render_flush: BoolProperty(
name="Render Flush",
description="Render 'flush' projection edges (dihedral deviation below both ridge/valley "
"thresholds). Omitted by default",
default=False,
update=get_update_layer_callback("render_flush", "RenderFlush"),
)
target_view: EnumProperty(
name="Target View",
default="PLAN_VIEW",
@@ -648,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.
@@ -110,14 +110,12 @@ class Scheduler:
y = self.margin
rows = list(sheet.iter_rows())
total_rows = len(rows)
x = None
for i, row in enumerate(rows):
# The last row may contain only null values
if i == (total_rows - 1) and not [c for c in row if c.value is not None]:
continue
x = self.margin
unmerged_height = None
for cell in row:
if isinstance(cell, openpyxl.cell.cell.MergedCell):
column_letter = openpyxl.utils.get_column_letter(cell.column)
@@ -232,11 +230,8 @@ class Scheduler:
)
x += unmerged_width
assert unmerged_height is not None
y += unmerged_height
assert x is not None
total_width = x + self.margin
total_height = y + self.margin
self.svg["width"] = "{}mm".format(total_width)
@@ -380,7 +375,6 @@ class Scheduler:
tri = 0
stop_iterating_over_rows = False
# TODO: row spans support?
x = None
for tr in table.getElementsByType(TableRow):
if stop_iterating_over_rows:
break
@@ -497,7 +491,6 @@ class Scheduler:
tri += 1
y += height
assert x is not None
total_width = x + self.margin
total_height = y + self.margin
self.svg["width"] = "{}mm".format(total_width)
@@ -102,16 +102,16 @@ void angle_circle_head(
in vec4 circle_start, in float circle_angle,
in bool counterclockwise,
out vec4 head[CIRCLE_SEGS+1], out float angle_segs) {
// 1 added to CIRCLE_SEGS because we're number of vertices
// for n segments is n+1
float angle_d;
angle_d = PI * 2 / CIRCLE_SEGS; // 30d
// need to bottom clamp it to 1, otherwise it causes Blender crash at extruding the curve
angle_segs = max(1, ceil(circle_angle / angle_d));
angle_d = circle_angle / angle_segs;
for(int i = 0; i < (angle_segs + 1); i++) {
float angle = angle_d * i;
if (counterclockwise) {
@@ -143,7 +143,7 @@ void cross_head(in vec4 dir, in float size, out vec4 head[3]) {
#define do_vertex(pos, e) (do_vertex_util(pos, vec2(-(e).y, (e).x) / winsize.xy))
#define do_vertex_win(pos, e) ( do_vertex( WIN2CLIP( pos ), e ) )
// if vertex is shared by two segments of the line still need to emit it twice
// if vertex is shared by two segments of the line still need to emit it twice
// to avoid smoothing artifacts
// don't forget to initialize `vec2 EDGE_DIR` for macro to work
// `pos0` / `pos1` - vertex position in clip space
@@ -197,13 +197,10 @@ void do_circle_head(vec4 pos_w, vec4 head[CIRCLE_SEGS]) {
def add_verts_sequence(verts, start_i, output_verts, output_edges, closed=False):
"""Add sequence of verts to output lists, returns next vertex index"""
i = None
for i, v in enumerate(verts[:-1], start_i):
output_verts.append(v)
output_edges.append((i, i + 1))
output_verts.append(verts[-1])
assert i is not None
if closed:
output_edges.append((i + 1, start_i))
return i + 2
@@ -276,7 +273,7 @@ class BaseShader:
FRAG_GLSL = """
uniform vec4 color;
uniform float lineWidth;
in float smoothline;
out vec4 fragColor;
void main() {
@@ -903,8 +903,12 @@ class SvgWriter:
continue
sheet = tool.Drawing.get_reference_document(sheet_reference)
if sheet:
reference_id = tool.Document.get_external_reference_id(sheet_reference) or "-"
sheet_id = tool.Document.get_document_information_id(sheet) or "-"
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)
break
return ("-", "-")
@@ -1449,7 +1453,6 @@ class SvgWriter:
angle_tg = rise / run
angle = round(degrees(atan(angle_tg)))
else:
angle_tg = None
angle = 90
# ues SLOPE_ANGLE as default
@@ -1463,7 +1466,6 @@ class SvgWriter:
elif object_type == "SLOPE_PERCENT":
if angle == 90:
return "-"
assert angle_tg is not None
return f"{round(angle_tg * 100)} %"
tag = element.Description or get_label_text()
+2 -21
View File
@@ -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")
@@ -113,19 +109,6 @@ class BIM_PT_camera(Panel):
row.prop(props, "fill_mode")
row = self.layout.row()
row.prop(props, "cut_mode")
row = self.layout.row()
row.prop(props, "use_edge_classification")
if props.use_edge_classification:
row = self.layout.row()
row.prop(props, "render_creases")
row.prop(props, "valley_angle_min_degrees")
row = self.layout.row()
row.prop(props, "render_sharp")
row.prop(props, "ridge_angle_min_degrees")
row = self.layout.row()
row.prop(props, "render_flush")
row = self.layout.row()
row.prop(props, "width")
row = self.layout.row()
@@ -332,8 +315,6 @@ class BIM_PT_drawings(Panel):
row3.separator(factor=0.5, type="SPACE")
row3.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="")
row3.operator("bim.select_all_drawings", icon="CHECKBOX_HLT", text="")
row3.operator("bim.create_drawing", text="", icon="OUTPUT")
row3.operator("bim.convert_svg_to_dxf", text="", icon="SEQ_PREVIEW").view = active_drawing.name
@@ -979,14 +960,14 @@ class BIM_UL_sheets(bpy.types.UIList):
if self.filter_name:
filter_name = self.filter_name.lower()
active_sheet_index = None
active_sheet = None
for sheet in data.sheets:
if sheet.is_sheet:
active_sheet = sheet
active_sheet_index = len(flt_flags)
if filter_name in sheet.name.lower() or filter_name in sheet.identification.lower():
flt_flags.append(self.bitflag_filter_item)
if not sheet.is_sheet:
assert active_sheet_index is not None
flt_flags[active_sheet_index] = self.bitflag_filter_item
else:
flt_flags.append(0)
@@ -225,9 +225,6 @@ class AnnotationToolUI:
def draw_edit_object_interface(cls, context):
if DecoratorData.get_text_data(bpy.context.active_object):
add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "")
if bpy.ops.bim.copy_annotation_to_drawing.poll():
row = cls.layout.row(align=True)
row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing")
@classmethod
def draw_type_selection_interface(cls):
@@ -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"])
@@ -75,13 +75,9 @@ class Helper:
for face in bm.faces:
if len(face.verts) > 4:
potential_faces.append(face)
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
profile = [l.vert.index for l in face.loops]
extrusion = self.detect_extrusion_edge(bm, face)
@@ -112,12 +108,10 @@ class Helper:
if not potential_faces:
potential_faces = bm.faces
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
profile = [l.vert.index for l in face.loops]
extrusion = self.detect_extrusion_edge(bm, face)
@@ -151,12 +145,9 @@ class Helper:
if total_verts > 4:
potential_faces.append(face)
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
end_faces = []
end_face_normal = face.normal
@@ -581,11 +581,7 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
if has_openings and not self.apply_openings:
# Meshlike things with openings can only be updated without openings applied.
if self.from_ui:
self.report(
{"ERROR"},
f"Object '{obj.name}' has openings. "
"ALT+click the button to bake the openings into the new representation.",
)
self.report({"ERROR"}, f"Object '{obj.name}' has openings - representation cannot be updated.")
return
if not product.is_a("IfcGridAxis"):
@@ -894,16 +890,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):
@@ -945,7 +931,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:
@@ -1044,10 +1030,7 @@ 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)
@@ -3527,15 +3510,12 @@ class EditRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator):
if props.representation_item_shape_aspect == "NEW":
active_representation = tool.Geometry.get_active_representation(obj)
# find IfcProductRepresentationSelect based on current representation
product_shape = None
if hasattr(element, "Representation"): # IfcProduct
product_shape = element.Representation
else: # IfcTypeProduct
for representation_map in element.RepresentationMaps:
if representation_map.MappedRepresentation == active_representation:
product_shape = representation_map
assert product_shape is not None
previous_shape_aspect_id = props.active_item.shape_aspect_id
# will be None if item didn't had a shape aspect
previous_shape_aspect = tool.Ifc.get_entity_by_id(previous_shape_aspect_id)
@@ -3885,8 +3865,6 @@ class AddSweptAreaSolidItem(bpy.types.Operator, tool.Ifc.Operator):
curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale)
elif self.shape == "CYLINDER":
curve = builder.circle(radius=0.25 / unit_scale)
else:
assert False, self.shape
item = builder.extrude(
curve,
magnitude=0.5 / unit_scale,
@@ -4124,31 +4102,6 @@ class OverrideMoveSelect(bpy.types.Operator):
self.new_active_obj = obj
return {"FINISHED"}
# Get arrays
ifc_file = tool.Ifc.get()
array_parents_to_move: list[bpy.types.Object] = []
for obj in list(context.selected_objects):
element = tool.Ifc.get_entity(obj)
if not element:
continue
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
parent_element = ifc_file.by_guid(pset["Parent"])
parent_obj = tool.Ifc.get_object(parent_element)
if parent_obj not in array_parents_to_move:
array_parents_to_move.append(parent_obj)
if element.GlobalId != pset["Parent"]:
obj.select_set(False)
if array_parents_to_move:
for parent_obj in array_parents_to_move:
parent_element = tool.Ifc.get_entity(parent_obj)
for array_obj in tool.Array.get_all_objects(parent_element):
array_obj.select_set(True)
self.new_active_obj = parent_obj
return {"FINISHED"}
# Get nests
props = tool.Nest.get_nest_props()
not_editing_objs = [o.obj for o in props.not_editing_objects]
+1 -1
View File
@@ -17,9 +17,9 @@
# 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 ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
@@ -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
+1 -39
View File
@@ -163,52 +163,14 @@ class AssignGroup(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
if not self.is_assigning:
return bpy.ops.bim.unassign_group(group=self.group)
ifc_file = tool.Ifc.get()
group = ifc_file.by_id(self.group)
products = [
element
for o in tool.Blender.get_selected_objects(include_active=False)
if (element := tool.Ifc.get_entity(o))
]
relocated_annotations = self.unassign_from_previous_drawing(ifc_file, group, products)
ifcopenshell.api.group.assign_group(ifc_file, products=products, group=group)
self.relocate_annotations_to_drawing(relocated_annotations, group)
ifcopenshell.api.group.assign_group(tool.Ifc.get(), products=products, group=tool.Ifc.get().by_id(self.group))
self.report({"INFO"}, f"Assigned {len(products)} objects to group.")
def unassign_from_previous_drawing(self, ifc_file, group, products) -> list[ifcopenshell.entity_instance]:
"""Assigning an annotation to a group that represents a drawing means the
annotation should belong to that drawing only, so it needs to leave
whichever drawing it was previously part of, instead of ending up
visible in both at once.
"""
new_drawing = tool.Drawing.get_group_drawing(group)
if not new_drawing:
return []
relocated = []
for product in products:
if not product.is_a("IfcAnnotation") or product.ObjectType == "DRAWING":
continue
old_drawing = tool.Drawing.get_annotation_drawing(product)
if not old_drawing or old_drawing.id() == new_drawing.id():
continue
if old_group := tool.Drawing.get_drawing_group(old_drawing):
ifcopenshell.api.group.unassign_group(ifc_file, products=[product], group=old_group)
relocated.append(product)
return relocated
def relocate_annotations_to_drawing(self, products, group) -> None:
"""Move the relocated annotations into the new drawing's collection and
depth, now that they have actually been assigned to its group.
"""
if not products:
return
new_drawing = tool.Drawing.get_group_drawing(group)
new_camera = tool.Ifc.get_object(new_drawing) or tool.Drawing.import_drawing(new_drawing)
for product in products:
if obj := tool.Ifc.get_object(product):
tool.Drawing.ensure_annotation_in_drawing_plane(obj, camera=new_camera)
tool.Collector.assign(obj)
class UnassignGroup(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_group"
+3 -1
View File
@@ -103,7 +103,9 @@ class LibraryReferencesData:
results.append(
{
"id": library.id(),
"identification": tool.Document.get_external_reference_id(library),
"identification": (
library.ItemReference if tool.Ifc.get_schema() == "IFC2X3" else library.Identification
),
"name": library.Name or "Unnamed",
}
)
@@ -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()
@@ -156,7 +156,6 @@ class RadianceRender(bpy.types.Operator):
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
print(f"Output directory: {output_dir}")
hdr_image_path, hdr_mask_path, sky_map_cal_path = None, None
if use_hdr:
hdr_image = "noon_grass_2k.hdr"
hdr_mask = "noon_grass_2k_mask.hdr"
@@ -255,9 +254,6 @@ class RadianceRender(bpy.types.Operator):
# 4 0 0 -1 180
if use_hdr and choose_hdr_image == "Noon":
assert hdr_image_path is not None
assert hdr_mask_path is not None
assert sky_map_cal_path is not None
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
@@ -418,13 +418,6 @@ class ImportQuickFavorites(bpy.types.Operator):
bl_description = "Import operators from Blender's Quick Favorites menu, including their configured properties"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if bpy.app.version[:2] not in tool.Misc.QuickFavorites.OFFSET_USER_MENUS:
cls.poll_message_set(f"Blender version {bpy.app.version_string} is not supported.")
return False
return True
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
props.quick_favorites.clear()
+3 -18
View File
@@ -27,7 +27,6 @@ import bonsai.tool as tool
from . import (
array,
covering,
decorator,
door,
external,
grid,
@@ -111,9 +110,6 @@ classes = (
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoPairDisconnect,
wall.GizmoSlabEdition,
wall.GizmoSlabUnjoinWalls,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
@@ -124,7 +120,7 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.DisconnectElements,
wall.UnjoinWallPathConnection,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
@@ -158,14 +154,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,
@@ -192,7 +185,6 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMSlabProperties,
prop.BIMWallProperties,
prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties,
@@ -254,13 +246,9 @@ classes = (
railing.CopyRailingParameters,
railing.AddRailing,
railing.CancelEditingRailing,
railing.CycleRailingType,
railing.FinishEditingRailing,
railing.PickRailingTerminalType,
railing.FlipRailingPathOrder,
railing.EnableEditingRailing,
railing.GizmoRailingSchematic,
railing.ToggleRailingUseManualSupports,
railing.CancelEditingRailingPath,
railing.FinishEditingRailingPath,
railing.EnableEditingRailingPath,
@@ -281,7 +269,9 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPUnjoinAtPort,
mep.MEPRemoveTerminalFitting,
mep.MEPUnjoinPair,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep_bend_preview.EnableBendPreview,
@@ -378,11 +368,6 @@ def unregister():
# half-unloaded module state.
opening.DecorationsHandler.uninstall()
# Network path overlays attach SpaceView3D draw handlers on toggle;
# uninstall here so addon disable / Blender shutdown doesn't leak them.
decorator.MEPSystemPathDecorator.uninstall()
decorator.WallSystemPathDecorator.uninstall()
if not bpy.app.background:
for tool_data in reversed(tools):
bpy.utils.unregister_tool(tool_data.tool)
+27 -40
View File
@@ -329,7 +329,6 @@ class _ArrayEditMixin(ParametricEditMixinBase):
# Unhide the (possibly newly-regenerated) children so the user sees
# the committed result. Mirrors the hide in ``_enable_one``.
cls._set_children_visibility(element, hidden=False)
tool.Array.select_only_parent(obj, context)
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
@@ -422,28 +421,18 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
arrays = json.loads(pset["Data"])
pset = tool.Ifc.get().by_id(pset["id"])
# Coalesce host recuts across the child-delete loop, the regenerate,
# and the per-child opening mirror: each fans out its own host body
# recut without the batch wrapper.
with tool.Geometry.batch_host_recut():
for array in arrays:
for child in set(array["children"]):
try:
child_element = tool.Ifc.get().by_guid(child)
except RuntimeError:
continue
if child_obj := tool.Ifc.get_object(child_element):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
# Always operate on the parent — this operator can be invoked with
# either the parent OR any array child as active_object (the per-child
# gizmo group fires it from a child selection). Using ``obj`` /
# ``element`` directly would feed a child to ``regenerate_array`` and
# constrain children against a sibling, silently corrupting the array.
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
tool.Array.select_only_parent(parent, context)
for array in arrays:
for child in set(array["children"]):
if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
# Always operate on the parent — this operator can be invoked with
# either the parent OR any array child as active_object (the per-child
# gizmo group fires it from a child selection). Using ``obj`` /
# ``element`` directly would feed a child to ``regenerate_array`` and
# constrain children against a sibling, silently corrupting the array.
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
@@ -478,24 +467,23 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
except:
return {"FINISHED"}
with tool.Geometry.batch_host_recut():
if self.keep_objs:
tool.Array.bake_children_transform(element, self.item)
tool.Array.set_children_lock_state(element, self.item, False)
if self.keep_objs:
tool.Array.bake_children_transform(element, self.item)
tool.Array.set_children_lock_state(element, self.item, False)
if not self.keep_objs:
data[self.item]["count"] = 1
tool.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
if not self.keep_objs:
data[self.item]["count"] = 1
tool.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
del data[self.item]
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Array.constrain_children_to_parent(element)
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
del data[self.item]
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Array.constrain_children_to_parent(element)
class SelectArrayParent(bpy.types.Operator):
@@ -564,7 +552,6 @@ class SelectAllArrayObjects(bpy.types.Operator):
except RuntimeError:
self.report({"ERROR"}, f"Objects that don't have an array parent, were deselected.")
object.select_set(False)
continue
array_objects = tool.Array.get_all_objects(parent_element)
tool.Blender.set_objects_selection(
@@ -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()
+224 -569
View File
@@ -21,7 +21,6 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from math import cos, pi, radians, sin, tan
from typing import Any, Literal, NamedTuple
@@ -44,7 +43,6 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.decorator_cache import TokenCache
from bonsai.bim.module.drawing.gizmos import (
ARC_SEGMENTS,
DOOR_SWING_ANGLE_MAX,
@@ -53,68 +51,15 @@ from bonsai.bim.module.drawing.gizmos import (
from bonsai.bim.module.drawing.helper import format_distance
def highlight_color(color, alpha=0.1):
color = [i + (1 - i) * 0.5 for i in color]
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
def _stroke_lines_alpha(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
line_width: float,
line_alpha: float,
) -> None:
"""Render ``segments`` (a list of (start, end) tuples) as one anti-aliased
LINES batch in world space. Early-returns when ``context.region`` is
unavailable (e.g. when called from a ``_RestrictContext``)."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(tuple(start))
verts.append(tuple(end))
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, line_alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
def _connected_components(
vertex_groups: dict[int, list[bmesh.types.BMVert]],
) -> list[list[bmesh.types.BMVert]]:
"""Split each vertex group's members into their connected components,
since a duplicated arc/circle loop shares its source loop's group index."""
components = []
for verts in vertex_groups.values():
remaining = set(verts)
while remaining:
seed = remaining.pop()
stack = [seed]
component = [seed]
while stack:
v = stack.pop()
for edge in v.link_edges:
other = edge.other_vert(v)
if other in remaining:
remaining.discard(other)
stack.append(other)
component.append(other)
components.append(component)
return components
def highlight_color(color, alpha=0.1):
color = [i + (1 - i) * 0.5 for i in color]
return color
class ProfileDecorator:
@@ -151,7 +96,7 @@ class ProfileDecorator:
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)
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
@@ -281,7 +226,7 @@ class ProfileDecorator:
self.draw_batch("LINES", all_vertices, unselected_elements_color, unselected_edges)
self.draw_batch("LINES", all_vertices, selected_elements_color, selected_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)
@@ -289,7 +234,7 @@ class ProfileDecorator:
# Draw arcs
arc_centroids = []
arc_segments = []
for arc in _connected_components(arcs):
for arc in arcs.values():
if len(arc) != 3:
continue
sorted_arc = [None, None, None]
@@ -316,7 +261,7 @@ class ProfileDecorator:
# Draw circles
circle_centroids = []
circle_segments = []
for circle in _connected_components(circles):
for circle in circles.values():
if len(circle) != 2:
continue
p1 = obj.matrix_world @ circle[0].co
@@ -372,11 +317,9 @@ class ProfileDecorator:
return points, listEdg
class PolylineDecorator(tool.Blender.ViewportDecorator):
# draw_methods declares only the always-bound handler so the base's
# __init_subclass__ validation passes; the override install below
# conditionally registers up to four more handlers based on ui_only.
draw_methods = (("draw_input_ui", "POST_PIXEL"),)
class PolylineDecorator:
is_installed = False
handlers = []
event = None
input_type = None
input_ui = None
@@ -412,6 +355,15 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
cls.handlers.append(SpaceView3D.draw_handler_add(handler, (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
@classmethod
def update(
cls,
@@ -470,6 +422,14 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
return {"verts": verts, "edges": edges, "tris": tris}
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 shader_config(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
self.decorator_color = self.addon_prefs.decorations_colour
@@ -737,9 +697,7 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
if self.polyline_data.measurement_type == "POLY_AREA" and area:
if float(area) > 0:
tris = self.calculate_polygon(polyline_verts)["tris"]
self.draw_batch(
"TRIS", polyline_verts, tool.Blender.transparent_color(self.decorator_color_special), tris
)
self.draw_batch("TRIS", polyline_verts, transparent_color(self.decorator_color_special), tris)
# Draw polyline with selected points
self.line_shader.uniform_float("lineWidth", 2.0)
@@ -992,8 +950,9 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
self.draw_batch("LINES", polyline_verts, decorator_color_unselected, polyline_edges)
class ProductDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_product_preview"
class ProductDecorator:
is_installed = False
handlers = []
preview_mode: Literal["PROFILE_VERTICAL", "PROFILE_HORIZONTAL", "LAYER2", "LAYER3", "GENERIC"]
relating_type = None
obj_data: dict[str, list] = {}
@@ -1036,7 +995,29 @@ class ProductDecorator(tool.Blender.ViewportDecorator):
)
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_product_preview(self, context):
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
self.addon_prefs = tool.Blender.get_addon_preferences()
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
self.line_shader.bind() # required to be able to change uniforms of the shader
@@ -1068,7 +1049,7 @@ class ProductDecorator(tool.Blender.ViewportDecorator):
data = self.get_generic_preview_data()
if data:
self.draw_batch("LINES", data["verts"], decorator_color, data["edges"])
self.draw_batch("TRIS", data["verts"], tool.Blender.transparent_color(decorator_color), data["tris"])
self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color), data["tris"])
def get_wall_preview_data(self):
relating_type = self.relating_type
@@ -1601,8 +1582,34 @@ class ProductDecorator(tool.Blender.ViewportDecorator):
return data
class WallAxisDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_wall_axis"
class WallAxisDecorator:
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_wall_axis, (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_wall_axis(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -1621,7 +1628,7 @@ class WallAxisDecorator(tool.Blender.ViewportDecorator):
self.line_shader.uniform_float("lineWidth", 2.0)
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if element and element.is_a("IfcWall"):
if element.is_a("IfcWall"):
layers = tool.Model.get_material_layer_parameters(element)
axis = tool.Model.get_wall_axis(obj, layers)
side = [tuple(list(v) + [obj.location.z]) for v in axis["side"]]
@@ -1641,8 +1648,34 @@ class WallAxisDecorator(tool.Blender.ViewportDecorator):
self.draw_batch("LINES", arrow, unselected_elements_color, [(0, 1), (1, 2), (1, 3)])
class SlabDirectionDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_wall_axis"
class SlabDirectionDecorator:
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_wall_axis, (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_wall_axis(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -1672,10 +1705,41 @@ class SlabDirectionDecorator(tool.Blender.ViewportDecorator):
self.draw_batch("LINES", base, selected_elements_color, [(0, 1)])
class FaceAreaDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_face_area"
class FaceAreaDecorator:
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_face_area, (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_face_area(self, context):
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
self.addon_prefs = tool.Blender.get_addon_preferences()
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
self.line_shader.bind() # required to be able to change uniforms of the shader
@@ -1696,16 +1760,12 @@ class FaceAreaDecorator(tool.Blender.ViewportDecorator):
if data:
self.draw_batch("POINTS", data["verts"], decorator_color)
self.draw_batch("LINES", data["verts"], decorator_color, data["edges"])
self.draw_batch(
"TRIS", data["verts"], tool.Blender.transparent_color(decorator_color, alpha=0.5), data["tris"]
)
self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color, alpha=0.5), data["tris"])
class BoundingBoxDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_bounding_box_wire_cube", "POST_VIEW"),
("draw_dimension_text", "POST_PIXEL"),
)
class BoundingBoxDecorator:
is_installed = False
handlers = []
def __init__(self):
context = bpy.context
@@ -1716,6 +1776,31 @@ class BoundingBoxDecorator(tool.Blender.ViewportDecorator):
self.decorator_color_wire = (*theme.view_3d.bone_solid, 1)
self.decorator_color_special = tool.Blender.get_addon_preferences().decorator_color_special
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(
bpy.types.SpaceView3D.draw_handler_add(
handler.draw_bounding_box_wire_cube, (context,), "WINDOW", "POST_VIEW"
)
)
cls.handlers.append(
bpy.types.SpaceView3D.draw_handler_add(handler.draw_dimension_text, (context,), "WINDOW", "POST_PIXEL")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
bpy.types.SpaceView3D.draw_handler_remove(handler, "WINDOW")
except Exception:
pass
cls.handlers.clear()
cls.is_installed = False
@staticmethod
def get_combined_bounding_box_corners(objects):
@@ -1788,6 +1873,14 @@ class BoundingBoxDecorator(tool.Blender.ViewportDecorator):
]
return trihedron[best_origin]
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_background(self, context, coords_dim, text_dim):
padding = 5
theme = context.preferences.themes.items()[0][1]
@@ -1939,6 +2032,46 @@ class BoundingBoxDecorator(tool.Blender.ViewportDecorator):
co2.y -= y_overlap / 2 + min_spacing
def _fill_quads_alpha(
context: bpy.types.Context,
quads: list[
tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]
],
color_rgb: tuple[float, float, float],
alpha: float,
) -> None:
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
order) as one TRIS batch with two triangles per quad."""
if not quads:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.extend(tuple(v) for v in quad)
indices.append((base, base + 1, base + 2))
indices.append((base, base + 2, base + 3))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", (*color_rgb, alpha))
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
def compute_mep_join_location():
"""Midpoint between the closest endpoint pair of two selected MEP
segments the world location where a connecting fitting (bend /
@@ -2396,481 +2529,3 @@ def draw_polyline_segments(
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
"""Shared scaffolding for "BFS-walk a connected IFC network from a selected
seed and overlay its schematic path" viewport decorators.
Subclasses implement three hooks:
``_is_seed_element(element)``: True if ``element`` can seed a walk
``_walk(start_element)``: list of network elements reachable from the seed
``_build_geometry(connected)``: ``(lines, free_points, connection_points)``
for one walk pass; free dots render in the base selected color,
connection dots in the "special" slot so junctions stand out
Lifecycle each redraw: gate on ``BIMModelProperties.show_paths`` (the
shared toggle for all network-path overlays), find the first selected
seed element, walk the network (cached per seed-GUID per IFC file), and
render lines + connection-node dots. Geometry is memoised through a
``TokenCache`` keyed on the decorator-cache token, so depsgraph / undo /
redo / load all invalidate the resolved world-space pass without
re-walking.
Install / uninstall is driven by the central addon-load handler and
by the toggle's ``update`` callback, so flipping the property takes
effect immediately without a Blender restart."""
# Network-path lines + junction dots render in ``decorator_color_selected``
# (Bonsai's palette slot for "what the user is currently inspecting"); free
# endpoints (dangling chain tips) switch to ``decorator_color_special`` so
# the end of the line stands apart from interior junctions at a glance.
LINE_WIDTH = 1.3
LINE_ALPHA = 0.85
# Sized larger than LINE_WIDTH so connection nodes read as discrete
# points rather than line thickenings.
DOT_SIZE = 4.0
# Squared distance under which two emitted dots are treated as the same
# connection node. In Blender units (typically meters), 1e-4 m ≈ 0.1 mm
# — below the precision at which two IFC reference-line endpoints would
# ever be authored as "the same join" but not so tight that float drift
# from coordinate composition misses a real coincidence.
CONNECTION_EPS_SQ = 1e-4 * 1e-4
def __init__(self) -> None:
# Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP
# integer ids rather than ``entity_instance`` references — re-resolved
# via ``ifc_file.by_id`` on each cache hit. Structurally rules out
# the dangling-SWIG-handle class of bug: an entity removed between
# frames either bumps geom_gen (cache miss → re-walk) or fails to
# re-resolve (handled below by re-walking). Compare ``ifc_file`` with
# ``is`` (not id()) so a GC-recycled id() can't produce a false hit.
self._cached_start_guid: str | None = None
self._cached_ifc_file: Any = None
self._cached_geom_gen: int = -1
self._cached_walk_ids: list[int] = []
# Geometry cache: shared TokenCache. Key folds in geom_gen so IFC
# mutations that don't surface via the depsgraph still flush the
# resolved world-space lines and dots.
self._geom_cache: TokenCache[
tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]
] = TokenCache()
# One-shot guards so a corrupted walk or build surfaces in the console
# once per decorator instance instead of every redraw.
self._walk_failure_logged: bool = False
self._build_failure_logged: bool = False
# Short-circuit re-running a known-broken walk or build for the same
# seed every frame; cleared the moment the user picks a different seed.
self._failed_seed_guid: str | None = None
_ABSTRACT_HOOKS = ("_is_seed_element", "_walk", "_build_geometry")
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
# Pin the template-method contract at class-definition time, mirroring
# ViewportDecorator's draw_method check: a subclass that forgets to
# override one of the three hooks would otherwise pass class creation
# and only raise NotImplementedError on the first walk — deferred long
# past the offending declaration.
missing = [
name for name in cls._ABSTRACT_HOOKS if getattr(cls, name) is getattr(_ConnectedNetworkPathDecorator, name)
]
if missing:
raise TypeError(f"{cls.__name__}: must override abstract hook(s) {sorted(missing)}")
def _is_seed_element(self, element: Any) -> bool:
raise NotImplementedError
def _walk(self, start_element: Any) -> list[Any]:
raise NotImplementedError
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
"""Resolve world-space line segments + dots for one walk pass. Returns
``(lines, free_points, connection_points)`` free dots get the base
selected color, connection dots get the special color so junctions
between two consecutive elements pop out. Never raises; skips
degenerate elements."""
raise NotImplementedError
@classmethod
def _partition_points_by_coincidence(
cls,
points: list[tuple[float, float, float]],
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]] = (),
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
"""Split ``points`` into ``(free, connection)``. A point is "connection"
when (a) at least one other point in the list lies within
``CONNECTION_EPS_SQ`` (corner / end-to-end joins), or (b) it lies within
``CONNECTION_EPS_SQ`` of the interior of any segment in ``lines``
(T-junctions / ATPATH joins, where one wall's end lands on another
wall's axis interior rather than its endpoint). Connection points
dedupe to one representative each so coincident dots don't stack the
same color."""
eps_sq = cls.CONNECTION_EPS_SQ
n = len(points)
shared = [False] * n
for i in range(n):
xi, yi, zi = points[i]
for j in range(i + 1, n):
xj, yj, zj = points[j]
dx, dy, dz = xi - xj, yi - yj, zi - zj
if dx * dx + dy * dy + dz * dz <= eps_sq:
shared[i] = True
shared[j] = True
for i, point in enumerate(points):
if shared[i]:
continue
if cls._point_touches_any_segment_interior(point, lines, eps_sq):
shared[i] = True
free: list[tuple[float, float, float]] = []
connection: list[tuple[float, float, float]] = []
seen_connection: list[tuple[float, float, float]] = []
for i, point in enumerate(points):
if not shared[i]:
free.append(point)
continue
for existing in seen_connection:
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
if dx * dx + dy * dy + dz * dz <= eps_sq:
break
else:
seen_connection.append(point)
connection.append(point)
return free, connection
@staticmethod
def _point_touches_any_segment_interior(
point: tuple[float, float, float],
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]],
eps_sq: float,
) -> bool:
"""True iff ``point`` lies within ``sqrt(eps_sq)`` of the interior of
any segment in ``lines``. Endpoints are excluded so a point cannot
match its own owning segment via either of that segment's tips — the
endpoint-coincidence pass already handles those cases. The qualifying
projection must land strictly inside the segment (``0 < t < 1``) AND
sit further than ``eps`` from either tip, catching ATPATH/T-junction
joins without false-flagging walls that share a corner."""
px, py, pz = point
for (ax, ay, az), (bx, by, bz) in lines:
dxa, dya, dza = px - ax, py - ay, pz - az
if dxa * dxa + dya * dya + dza * dza <= eps_sq:
continue
dxb, dyb, dzb = px - bx, py - by, pz - bz
if dxb * dxb + dyb * dyb + dzb * dzb <= eps_sq:
continue
ex, ey, ez = bx - ax, by - ay, bz - az
seg_len_sq = ex * ex + ey * ey + ez * ez
if seg_len_sq <= eps_sq:
continue
t = (dxa * ex + dya * ey + dza * ez) / seg_len_sq
if t <= 0.0 or t >= 1.0:
continue
qx, qy, qz = ax + t * ex, ay + t * ey, az + t * ez
dx, dy, dz = px - qx, py - qy, pz - qz
if dx * dx + dy * dy + dz * dz <= eps_sq:
return True
return False
def draw(self, context: bpy.types.Context) -> None:
model_props = tool.Model.get_model_props()
if not getattr(model_props, "show_paths", False):
return
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
start_element = None
active = context.active_object
if active is not None:
element = tool.Ifc.get_entity(active)
if element is not None and self._is_seed_element(element):
start_element = element
if start_element is None:
for obj in context.selected_objects or []:
if obj is active:
continue
element = tool.Ifc.get_entity(obj)
if element is None or not self._is_seed_element(element):
continue
start_element = element
break
if start_element is None:
self._cached_start_guid = None
self._cached_walk = []
return
start_guid = start_element.GlobalId
if start_guid == self._failed_seed_guid:
return
current_geom_gen = tool.Parametric.get_geom_generation()
connected: list[Any] | None = None
if (
start_guid == self._cached_start_guid
and ifc_file is self._cached_ifc_file
and current_geom_gen == self._cached_geom_gen
and self._cached_walk_ids
):
try:
connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids]
except RuntimeError:
# An entity was removed without bumping geom_gen — rare but
# possible from non-operator code paths. Force a re-walk
# rather than feeding a stale handle to _build_geometry.
connected = None
if connected is None:
try:
connected = self._walk(start_element)
except Exception:
if not self._walk_failure_logged:
import traceback
traceback.print_exc()
self._walk_failure_logged = True
self._cached_walk_ids = []
self._failed_seed_guid = start_guid
return
self._cached_start_guid = start_guid
self._cached_ifc_file = ifc_file
self._cached_geom_gen = current_geom_gen
self._cached_walk_ids = [e.id() for e in connected]
if not connected:
return
prefs = tool.Blender.get_addon_preferences()
line_color = tuple(prefs.decorator_color_selected[:3])
# Junction dots get the "selected" palette slot (green by default) so
# they read as the currently-inspected network's spine; free endpoints
# get the "special" slot (blue by default) so dangling line ends stand
# apart from junctions at a glance.
connection_color = line_color
free_color = tuple(prefs.decorator_color_special[:3])
try:
lines, free_points, connection_points = self._geom_cache.get_or_compute(
(start_guid, id(ifc_file), current_geom_gen),
lambda: self._build_geometry(connected),
)
except Exception:
if not self._build_failure_logged:
import traceback
traceback.print_exc()
self._build_failure_logged = True
self._failed_seed_guid = start_guid
return
if lines:
_stroke_lines_alpha(context, lines, line_color, self.LINE_WIDTH, self.LINE_ALPHA)
if free_points or connection_points:
# POINTS via UNIFORM_COLOR; point_size_set only affects the next batch.
point_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
point_shader.bind()
gpu.state.point_size_set(self.DOT_SIZE)
gpu.state.blend_set("ALPHA")
if free_points:
point_shader.uniform_float("color", (*free_color, self.LINE_ALPHA))
batch = batch_for_shader(point_shader, "POINTS", {"pos": free_points})
batch.draw(point_shader)
if connection_points:
point_shader.uniform_float("color", (*connection_color, self.LINE_ALPHA))
batch = batch_for_shader(point_shader, "POINTS", {"pos": connection_points})
batch.draw(point_shader)
gpu.state.blend_set("NONE")
class MEPSystemPathDecorator(_ConnectedNetworkPathDecorator):
"""Schematic-path overlay for the selected MEP element's connected
distribution system.
Walk: BFS through ``IfcRelConnectsPorts`` from the first selected MEP
element. Segments render as one axis line + endpoint dots. Fittings
render as:
- 2-port (transition, coupler, bend): one line port-to-port, keeping
the schematic continuous through the fitting. The "spider from
origin" pattern produces V-shaped flares when the fitting's local
origin is offset from its ports.
- 3+-port (tee, cross, branching): spider from origin to each port.
Drawing all N*(N-1)/2 port pairs would clutter the view at high N
(N=4 6 lines); the spider gives one line per port.
- 0-port / 1-port: degenerate, no lines (dots still emit)."""
def _is_seed_element(self, element: Any) -> bool:
return tool.System.is_mep_element(element)
def _walk(self, start_element: Any) -> list[Any]:
return tool.System.walk_connected_mep_elements(start_element)
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
port_positions: list[tuple[float, float, float]] = []
for element in connected:
if element and element.is_a("IfcFlowSegment"):
if not tool.Geometry.has_axis_representation(element):
continue
obj = tool.Ifc.get_object(element)
if obj is None:
continue
start_world, end_world = tool.Model.get_flow_segment_axis(obj)
lines.append((tuple(start_world), tuple(end_world)))
# Segment ports sit at the two axis endpoints — emit dots so
# the connection node is visible whether the neighbour is a
# fitting (also emits) or another segment (doesn't).
port_positions.append(tuple(start_world))
port_positions.append(tuple(end_world))
elif element.is_a("IfcFlowFitting"):
obj = tool.Ifc.get_object(element)
if obj is None:
continue
ports = tool.System.get_ports(element)
port_world_positions = [tool.System.get_port_world_position(p) for p in ports]
if len(port_world_positions) == 2:
lines.append((tuple(port_world_positions[0]), tuple(port_world_positions[1])))
elif len(port_world_positions) >= 3:
origin = obj.matrix_world.translation
for port_pos in port_world_positions:
lines.append((tuple(origin), tuple(port_pos)))
for port_pos in port_world_positions:
port_positions.append(tuple(port_pos))
free_points, connection_points = self._partition_points_by_coincidence(port_positions)
return lines, free_points, connection_points
class WallSystemPathDecorator(_ConnectedNetworkPathDecorator):
"""Schematic-path overlay for the selected wall's connected wall network.
Walk: BFS through ``IfcRelConnectsPathElements`` from the first selected
wall. Each wall renders as one reference-line segment + a dot at each
axis endpoint. Endpoints are classified by IFC topology every wall in
the walked set inspects its ``IfcRelConnectsPathElements`` rels filtered
to walls in the same set, and uses ``Relating*``/``Related*ConnectionType``
(ATSTART / ATEND / ATPATH) to decide which endpoint participates. ATPATH
rels also emit a connection dot at the canonical join location (a T-meets
point sits on the through-wall's interior, not at any endpoint). The
framework's geometric classifier is bypassed for walls because authoring
tolerance and post-edit float drift commonly exceed the 0.1 mm coincidence
threshold, so T-junctions otherwise fell into the free bucket."""
def _is_seed_element(self, element: Any) -> bool:
return element.is_a("IfcWall") and tool.Geometry.has_axis_representation(element)
def _walk(self, start_element: Any) -> list[Any]:
return tool.Wall.walk_connected_walls(start_element)
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
for element in connected:
obj = tool.Ifc.get_object(element)
if obj is None:
continue
ref = tool.Wall.get_world_reference_line(obj)
if ref is None:
continue
p1, p2 = tuple(ref[0]), tuple(ref[1])
refs[element.id()] = (p1, p2)
lines.append((p1, p2))
free_points, connection_points = self._classify_endpoints_from_rels(connected, refs)
connection_points = self._dedupe_close_points(connection_points, self.CONNECTION_EPS_SQ)
return lines, free_points, connection_points
@staticmethod
def _classify_endpoints_from_rels(
connected: Sequence[Any],
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]],
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
"""For each wall in ``connected`` with a reference line in ``refs``,
classify its endpoints by walking its ``IfcRelConnectsPathElements``
rels filtered to walls also in ``refs``. ATSTART side present
reference-line start is a connection; ATEND side present reference-
line end is a connection; otherwise free. ATPATH side present emit
an extra connection dot at the canonical join via
``tool.Wall.path_connection_location_world``. Returns
``(free, connection)`` un-deduped."""
free_points: list[tuple[float, float, float]] = []
connection_points: list[tuple[float, float, float]] = []
for element in connected:
self_seg = refs.get(element.id())
if self_seg is None:
continue
sides: set[str] = set()
atpath_dots: list[tuple[float, float, float]] = []
for rel in getattr(element, "ConnectedTo", []) or ():
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatedElement
other_seg = refs.get(other.id()) if other is not None else None
if other_seg is None:
continue
self_type = rel.RelatingConnectionType
other_type = rel.RelatedConnectionType
sides.add(self_type)
if self_type == "ATPATH":
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
atpath_dots.append(tuple(join))
for rel in getattr(element, "ConnectedFrom", []) or ():
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatingElement
other_seg = refs.get(other.id()) if other is not None else None
if other_seg is None:
continue
self_type = rel.RelatedConnectionType
other_type = rel.RelatingConnectionType
sides.add(self_type)
if self_type == "ATPATH":
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
atpath_dots.append(tuple(join))
p1, p2 = self_seg
(connection_points if "ATSTART" in sides else free_points).append(p1)
(connection_points if "ATEND" in sides else free_points).append(p2)
connection_points.extend(atpath_dots)
return free_points, connection_points
@staticmethod
def _dedupe_close_points(
points: Sequence[tuple[float, float, float]],
eps_sq: float,
) -> list[tuple[float, float, float]]:
"""Drop later occurrences of points within ``sqrt(eps_sq)`` of an
earlier one. Used to collapse overlapping connection dots so an ATPATH
join computed at the same point as a neighbour's wall endpoint
renders once."""
result: list[tuple[float, float, float]] = []
for point in points:
for existing in result:
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
if dx * dx + dy * dy + dz * dz <= eps_sq:
break
else:
result.append(point)
return result
@@ -33,7 +33,6 @@ 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,
@@ -53,20 +52,6 @@ def is_supported_host(element) -> bool:
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
@@ -87,14 +72,12 @@ def _resolve_active_host(context: bpy.types.Context, n_selected: int):
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).
"""Activates when a host element (wall / slab / roof) is the active object
and exactly one other selected object is *not* itself a host.
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.
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
projected location on the host. A click dispatches ``bim.add_opening``,
which handles any element exposing the ``HasOpenings`` inverse.
Per-frame positioning keeps the icon facing the camera as the viewport
orbits."""
@@ -107,29 +90,22 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
element = _resolve_active_host(context, n_selected=2)
if element is None:
return False
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
# The operator itself filters on HasOpenings, but checking here keeps
# the icon from appearing on host classes that can't accept openings
# in the active IFC schema.
if not hasattr(element, "HasOpenings"):
return False
active = context.active_object
if active is None or active not in selected:
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
# Host + host pairings are claimed by host-specific gizmos (wall-join,
# extend-vertical, …) — suppress here so the add-opening icon never
# stacks on top of them.
if is_supported_host(tool.Ifc.get_entity(other)):
return False
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)
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
@@ -138,20 +114,18 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
)
def position_gizmos(self, context: bpy.types.Context) -> None:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
host_obj = context.active_object
if not host_obj:
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:
selected = tool.Blender.get_selected_objects()
other = next((o for o in selected if o is not host_obj), None)
if not other:
return
element = tool.Ifc.get_entity(host_obj)
if not element:
return
if tool.Parametric.is_path_connectable_wall(host_element):
if tool.Parametric.is_path_connectable_wall(element):
world_pos = wall_anchor(context, self, host_obj, other)
else:
world_pos = layer3_anchor(host_obj, other)
+128 -95
View File
@@ -38,7 +38,6 @@ import numpy as np
from ifcopenshell.util.shape_builder import ShapeBuilder
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
@@ -408,8 +407,6 @@ class MEPGenerator:
compare = tool.Cad.is_x(requested_value, fitting_value, compare_precision)
elif isinstance(fitting_value, list):
compare = tool.Cad.are_vectors_equal(requested_value, Vector(fitting_value), precision)
else:
assert False, f"{key} {second_key}"
return compare
ignore_keys = []
@@ -478,13 +475,11 @@ class MEPGenerator:
if predefined_type == "OBSTRUCTION":
return packed_data
start_port = None
for port in ports:
port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates)
if tool.Cad.is_x(port_local_position.length, 0.0):
start_port = port
break
assert start_port is not None
connected_port = tool.System.get_connected_port(start_port)
connected_element = tool.System.get_port_relating_element(connected_port)
@@ -698,6 +693,27 @@ def get_connected_element_at_segment_port(segment, at_segment_start):
return tool.System.get_port_relating_element(connected_port)
def find_fitting_between_segments(segment_a, segment_b):
"""Single IfcFlowFitting bridging segment_a and segment_b via ports, or
``None`` if no fitting (or multiple fittings only direct one-fitting
joins handled)."""
if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")):
return None
b_ports_set = set(tool.System.get_ports(segment_b))
for a_port in tool.System.get_ports(segment_a):
connected_port = tool.System.get_connected_port(a_port)
if connected_port is None:
continue
fitting = tool.System.get_port_relating_element(connected_port)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
continue
for fitting_port in tool.System.get_ports(fitting):
other_port = tool.System.get_connected_port(fitting_port)
if other_port is not None and other_port in b_ports_set:
return fitting
return None
def _resolve_active_mep_segment(operator, context):
"""Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting.
@@ -792,6 +808,52 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting that bridges a segment's port to a second element.
Used when the connection at the port is in the JOINED state (the fitting
has at least one other port connecting to a different element). The
segment isn't resized — only the bridging fitting is removed. Refuses
to act on an OBSTRUCTION fitting (those are routed through
``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment
to absorb the freed length)."""
bl_idname = "bim.mep_unjoin_at_port"
bl_label = "Unjoin MEP Segment at Port"
bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)"
bl_options = {"REGISTER", "UNDO"}
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
position: bpy.props.EnumProperty(
name="Port",
items=[
("START", "At Start", "Operate on the segment's start port"),
("END", "At End", "Operate on the segment's end port"),
],
default="END",
)
def _execute(self, context):
resolved = _require_port_state(self, context, PORT_JOINED, "joining")
if resolved is None:
return {"CANCELLED"}
element, at_segment_start = resolved
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
self.report({"ERROR"}, "Connected port does not lead to a fitting.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
"""Remove the terminal fitting at a segment's named port.
@@ -844,6 +906,44 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting joining two selected MEP segments.
Removes the fitting; segments are left in place for the user to reposition."""
bl_idname = "bim.mep_unjoin_pair"
bl_label = "Unjoin MEP Segments"
bl_description = "Delete the fitting joining the two selected MEP segments"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.")
return False
return True
def _execute(self, context):
selected_objs = tool.Blender.get_selected_objects()
elements = [tool.Ifc.get_entity(o) for o in selected_objs]
if any(e is None or not e.is_a("IfcFlowSegment") for e in elements):
self.report({"ERROR"}, "Both selected objects must be MEP segments.")
return {"CANCELLED"}
fitting = find_fitting_between_segments(elements[0], elements[1])
if fitting is None:
self.report({"ERROR"}, "No single fitting joins the selected segments.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class SelectMEPPathMembers(bpy.types.Operator):
"""Replace the selection with every MEP element reachable from the active
one via IfcRelConnectsPorts the entire connected distribution network."""
@@ -1682,11 +1782,6 @@ def _n_mep_selected(n: int) -> bool:
element = tool.Ifc.get_entity(selected_obj)
if element is None or not tool.System.is_mep_element(element):
return False
# Array children mirror their parent's port topology. Writable MEP
# actions on a child get wiped by the next array regen, so gate the
# icons out at the visibility layer.
if tool.Array.is_array_child(element):
return False
return True
@@ -2565,8 +2660,6 @@ def _active_is_flow_segment(obj: bpy.types.Object) -> bool:
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcFlowSegment"):
return False
if tool.Array.is_array_child(element):
return False
return tool.System.has_parametric_body(element)
@@ -2584,24 +2677,10 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
"""True iff the active object is a parametric BEND fitting eligible for
the bend-preview re-edit path. Re-edit reads parameters from the type's
``BBIM_Fitting`` pset, so that pset's presence is the ground truth for
re-editability not the body representation class. The bend creation
path tessellates the swept-disk body as an upstream-geometry-kernel
workaround, so a freshly-committed bend's body contains only an
``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly
returns False for it; the pset gate is what keeps the pen icon
eligible."""
element = tool.Ifc.get_entity(obj)
if not _is_bend_fitting(element):
return False
if tool.Array.is_array_child(element):
return False
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
return tool.System.has_parametric_body(element)
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@@ -2684,20 +2763,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
),
IconActionConfig(
name="unjoin_start",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_end",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_pair",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_pair",
visibility_condition=lambda _active: _n_mep_selected(2),
),
]
@@ -2715,17 +2794,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return False
if tool.System.has_parametric_body(element):
return True
# Bend fittings carry their parametric definition in the type's
# ``BBIM_Fitting`` pset because the bend creation path tessellates
# the swept-disk body (upstream geometry-kernel workaround), so
# ``has_parametric_body`` returns False for them. Fall back to the
# pset gate so the pen icon (re_edit_bend) stays reachable.
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
return tool.System.has_parametric_body(element)
def setup(self, context: bpy.types.Context) -> None:
super().setup(context)
@@ -2733,13 +2802,11 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@classmethod
def _wire_anchored_icon_targets(cls, group) -> None:
"""Pre-fill ``position`` (and ``mode`` for open-lock) on the lock
icons so a click dispatches to the right port without a per-frame
property write, and pre-bind the unified ``bim.disconnect_elements``
operator on each unjoin icon so :py:meth:`position_gizmos` only has
to update the two GUIDs per frame. Takes any object with
``action_<name>_gizmo`` attributes so tests can exercise the wiring
without instantiating the GizmoGroup."""
"""Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored
icon so a click dispatches to the right port without a per-frame
property write; apply the warning-red hover colour to destructive
icons. Takes any object with ``action_<name>_gizmo`` attributes so
tests can exercise the wiring without instantiating the GizmoGroup."""
for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items():
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
@@ -2753,12 +2820,19 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting")
op_props.position = position_arg
group.unjoin_op_props = {}
for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
op_props = gz.target_set_operator("bim.mep_unjoin_at_port")
op_props.position = position_arg
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
for config_name in cls.UNJOIN_CONFIGS:
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements")
gz.color_highlight = warning_color
def position_gizmos(self, context: bpy.types.Context) -> None:
"""Lay out icons across three regions: row above bbox top, segment
@@ -2825,10 +2899,6 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if not visible:
gz.hide = True
continue
if config.name.startswith("unjoin_"):
if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"):
gz.hide = True
continue
if segment_endpoints is None:
segment_endpoints = tool.Model.get_flow_segment_axis(obj)
start_world, end_world = segment_endpoints
@@ -2840,7 +2910,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if len(selected) == 2:
elements = [tool.Ifc.get_entity(o) for o in selected]
if all(e is not None and e.is_a("IfcFlowSegment") for e in elements):
pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False
pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False
else:
pair_fitting = False
else:
@@ -2852,13 +2922,6 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
gz.hide = True
continue
if config.name == "unjoin_pair":
selected = tool.Blender.get_selected_objects()
pair_elements = [tool.Ifc.get_entity(o) for o in selected]
if not self._bind_unjoin_pair(pair_elements):
gz.hide = True
continue
if not bend_anchor_attempted:
bend_anchor = compute_mep_join_location()
bend_anchor_attempted = True
@@ -2887,33 +2950,3 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if name in self.ENDPOINT_CONFIGS:
return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO
return self.ICON_SCALE
def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool:
"""Resolve the fitting at the named port and bind both GUIDs on the
pre-wired ``bim.disconnect_elements`` op_props. Returns False when
the partner is unresolvable (port not joined to a disconnectable
fitting), and the caller hides the icon."""
element = tool.Ifc.get_entity(segment_obj)
if element is None:
return False
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
return False
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
return False
op_props = self.unjoin_op_props[config_name]
op_props.element_a_guid = element.GlobalId
op_props.element_b_guid = fitting.GlobalId
return True
def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool:
"""Bind both segment GUIDs on the pair-disconnect icon's pre-wired
``bim.disconnect_elements`` op_props. Returns False when either side
is missing a GlobalId (e.g. selection lost an active object), and
the caller hides the icon."""
if len(pair_elements) != 2 or any(e is None for e in pair_elements):
return False
op_props = self.unjoin_op_props["unjoin_pair"]
op_props.element_a_guid = pair_elements[0].GlobalId
op_props.element_b_guid = pair_elements[1].GlobalId
return True
+42 -67
View File
@@ -240,15 +240,6 @@ def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch
_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(
self,
@@ -418,16 +409,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 +469,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 +609,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 +637,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()
@@ -986,29 +964,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 +1022,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 +1051,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"}
@@ -325,7 +325,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator):
if self.from_invoke and str(self.relating_type_id) in AuthoringData.data["relating_type_id"]:
props.relating_type_id = str(self.relating_type_id)
building_obj, building_element = None, None
building_obj = None
if len(context.selected_objects) == 1 and context.active_object:
building_obj = context.active_object
building_element = tool.Ifc.get_entity(building_obj)
@@ -593,8 +593,6 @@ class DumbProfileJoiner:
axisl = (profile2.matrix_world.inverted() @ axis1[1]) - (profile2.matrix_world.inverted() @ axis1[0])
elif connection1 == "ATSTART":
axisl = (profile2.matrix_world.inverted() @ axis1[0]) - (profile2.matrix_world.inverted() @ axis1[1])
else:
assert False, connection1
xy_angle = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
if xy_angle >= -135 and xy_angle <= -45:
closest_plane = "bottom"
@@ -619,8 +617,6 @@ class DumbProfileJoiner:
axisl = (profile1.matrix_world.inverted() @ axis2[1]) - (profile1.matrix_world.inverted() @ axis2[0])
elif connection2 == "ATSTART":
axisl = (profile1.matrix_world.inverted() @ axis2[0]) - (profile1.matrix_world.inverted() @ axis2[1])
else:
assert False, connection2
xy_angle2 = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
if xy_angle2 >= -135 and xy_angle2 <= -45:
closest_plane2 = "bottom"
@@ -848,8 +844,6 @@ class DumbProfileJoiner:
else:
y_axis = obj.matrix_world.to_quaternion() @ Vector((0, 1, 0))
z_axis = obj.matrix_world.to_quaternion() @ Vector((-1, 0, 0))
else:
assert False, plane
return self.create_matrix(p, x_axis, y_axis, z_axis)
def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix:
@@ -1008,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")
+5 -45
View File
@@ -33,10 +33,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
@@ -134,19 +132,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)
@@ -243,7 +228,11 @@ def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Co
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
_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:
@@ -369,19 +358,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 +405,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
@@ -1718,21 +1693,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.
+11 -650
View File
@@ -18,7 +18,6 @@
import json
import math
from typing import Any
import bmesh
@@ -28,24 +27,14 @@ 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_
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -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: "prop.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)
@@ -473,8 +407,9 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
class _RailingEditMixin(PathPreservingEditMixin):
"""Single-object (active_object) railing-edit hooks; path_data is preserved
through the edit (path editing is a separate operator family)."""
"""Type-specific hooks for railing parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
pset_name = "BBIM_Railing"
@@ -501,21 +436,7 @@ class _RailingEditMixin(PathPreservingEditMixin):
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
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_bmesh(context)
@@ -546,556 +467,6 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera
return self._finish_targets(context)
class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
"""Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses."""
bl_idname = "bim.cycle_railing_type"
bl_label = "Cycle Railing Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_railing
props_getter = tool.Model.get_railing_props
type_literal = tool.Model.RailingType
type_attr = "railing_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
class ToggleRailingUseManualSupports(bpy.types.Operator):
"""Flip use_manual_supports on the active WALL_MOUNTED_HANDRAIL railing.
No-op unless a parametric edit is active and the railing is wall-mounted.
"""
bl_idname = "bim.toggle_railing_use_manual_supports"
bl_label = "Toggle Railing Manual Supports"
bl_description = "Switch between automatic support spacing and manual per-vertex placement"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
resolved = tool.Model.resolve_active_props_for_edit(
context,
tool.Model.get_railing_props,
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
)
if resolved is None:
return {"CANCELLED"}
_obj, props = resolved
props.use_manual_supports = not props.use_manual_supports
return {"FINISHED"}
class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
bl_idname = "bim.pick_railing_terminal_type"
bl_label = "Pick Railing Terminal Type"
bl_description = "Pick the cap geometry applied at the rail ends"
bl_options = {"REGISTER", "UNDO"}
skip_element_check = True
props_getter = tool.Model.get_railing_props
type_literal = prop.CapType
type_attr = "terminal_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
if (
tool.Model.resolve_active_props_for_edit(
context,
tool.Model.get_railing_props,
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
)
is None
):
return {"CANCELLED"}
return self._pick_type(context)
def _format_attr_distance(attr_name: str):
"""text_formatter that renders the named property as a distance, ignoring the
dimension's visible-length argument (which is fixed for schematic gizmos)."""
return lambda p, _v: tool.Unit.format_distance(getattr(p, attr_name))
class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup):
"""Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode."""
bl_idname = "OBJECT_GGT_bim_railing_edition"
bl_label = "Railing Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_railing"
finish_editing_operator = "bim.finish_editing_railing"
cancel_editing_operator = "bim.cancel_editing_railing"
cycle_type_operator = "bim.cycle_railing_type"
props_getter = tool.Model.get_railing_props
gizmo_pref_name = "railing"
# Schematic-local layout. +X → screen RIGHT, +Y → screen UP, +Z → toward viewer
# (post billboard rotation). Each dimension is anchored alongside the feature it
# measures so the label, not the bar length, carries the value.
SCHEMATIC_MESH_HEIGHT_FRAC = 0.9 # Mesh top edge in schematic-local +Y
SCHEMATIC_MESH_WIDTH_FRAC = 0.7 # Mesh side edges in schematic-local ±X
SCHEMATIC_MESH_RAIL_Y_FRAC = SCHEMATIC_MESH_HEIGHT_FRAC / 2 # WALL_MOUNTED_HANDRAIL rail centreline
SCHEMATIC_MESH_DEPTH_FRAC = 0.06 # Panel depth — small so the schematic reads as slabs not boxes
# WALL_MOUNTED_HANDRAIL dimensions — fractions of schematic_box_size so they
# scale with the host group's box size.
SCHEMATIC_RAIL_RADIUS_FRAC = 0.05
SCHEMATIC_RAIL_CLEAR_FRAC = 0.5 # Stylised — wider than real-world for visible bracket arm
SCHEMATIC_RAIL_INSET_FRAC = 0.08 # Wall extends past the outermost support on both sides
@classmethod
def schematic_rail_radius(cls) -> float:
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_RADIUS_FRAC
@classmethod
def schematic_rail_clear(cls) -> float:
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_CLEAR_FRAC
# Axonometric 3/4 view: +Z projects down-and-left so the depth axis
# is visibly separated from the back face. Without the X tilt, panel
# thickness (schematic-local Z) collapses to a near-horizontal bar.
schematic_view_rotation = Matrix.Rotation(math.radians(20), 4, "X") @ Matrix.Rotation(math.radians(-25), 4, "Y")
# Hover a dimension → highlight the schematic edges tagged with the matching feature.
# Tags are written by the mesh builders. "spacing" is empty space (no edges) so it's
# absent from this map and gracefully no-ops on hover.
schematic_attr_to_feature = {
"height": "panel_height",
"thickness": "panel_thickness",
"railing_diameter": "rail_tube",
"clear_width": "bracket",
"support_spacing": "bracket",
}
schematic_dimension_props = [
# ── FRAMELESS_PANEL ─────────────────────────────────────────────
DimensionGizmoConfig(
attr_name="height",
axis=(0, 1, 0),
min_value=0.01,
# Gated to FRAMELESS_PANEL: in WALL_MOUNTED_HANDRAIL, height only
# feeds TO_FLOOR / TO_END_POST_AND_FLOOR terminals so dragging it
# is a no-op under the default "180" terminal.
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector((-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.08, 0.0, 0.0)),
schematic_visible_length=SCHEMATIC_MESH_HEIGHT_FRAC,
text_formatter=_format_attr_distance("height"),
),
DimensionGizmoConfig(
attr_name="thickness",
axis=(0, 0, 1), # panel depth — projects to a true depth direction under the 3/4 tilt
min_value=0.005,
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector(
(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
- GizmoRailingSchematic.SCHEMATIC_MESH_GAP_HALF_WIDTH
)
/ 2,
GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC + 0.05,
-GizmoRailingSchematic.SCHEMATIC_MESH_DEPTH_FRAC / 2,
)
),
schematic_visible_length=0.4, # longer than default to survive depth foreshortening
text_formatter=_format_attr_distance("thickness"),
),
DimensionGizmoConfig(
attr_name="spacing",
axis=(1, 0, 0),
min_value=0.0, # zero-spacing collapses the picket gap into a single continuous panel
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector((0.0, -0.1, 0.0)),
text_formatter=_format_attr_distance("spacing"),
),
# ── WALL_MOUNTED_HANDRAIL ──────────────────────────────────────
DimensionGizmoConfig(
attr_name="railing_diameter",
axis=(0, 1, 0),
min_value=0.001,
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
matrix_position=lambda p: Vector(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.05,
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC - 0.09,
GizmoRailingSchematic.schematic_rail_clear(),
)
),
text_formatter=_format_attr_distance("railing_diameter"),
),
DimensionGizmoConfig(
attr_name="clear_width",
axis=(0, 0, 1), # +Z is the wall-to-rail perpendicular axis under the 3/4 tilt
min_value=0.001,
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
matrix_position=lambda p: Vector(
(
0.0,
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC,
0.0,
)
),
schematic_visible_length=0.36, # 2× default so the call-out survives depth projection
text_formatter=_format_attr_distance("clear_width"),
),
DimensionGizmoConfig(
attr_name="support_spacing",
axis=(1, 0, 0),
min_value=0.05,
visibility_condition=lambda p: (p.railing_type == "WALL_MOUNTED_HANDRAIL" and not p.use_manual_supports),
matrix_position=lambda p: Vector(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
+ GizmoRailingSchematic.SCHEMATIC_RAIL_INSET_FRAC,
-0.18,
0.0,
)
),
# Bare names (not Gizmo…SCHEMATIC_…) because the class is still under construction here.
schematic_visible_length=SCHEMATIC_MESH_WIDTH_FRAC - 2 * SCHEMATIC_RAIL_INSET_FRAC,
text_formatter=_format_attr_distance("support_spacing"),
),
]
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_railing(element)
@classmethod
def schematic_cache_key(cls, props) -> tuple:
"""Cache the schematic mesh by ``railing_type`` — proportions are fixed
per type, so the bmesh build runs at most twice across a session
(once for ``FRAMELESS_PANEL``, once for ``WALL_MOUNTED_HANDRAIL``)
rather than once per draw call."""
return (props.railing_type,)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create the WALL_MOUNTED_HANDRAIL-only affordances on the schematic.
Two static lock glyphs (open/closed) for toggling
``use_manual_supports``: instantiate both and let the per-frame state
query pick which one to show. State-aware icons use a static pair
rather than a single dynamic gizmo to avoid ``prop_path`` resolution
in the render path.
Plus a cycle-glyph at the rail end that opens the ``terminal_type``
popup when clicked.
"""
default_color, highlight_color = self.get_decoration_colors()
self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
"bim.toggle_railing_use_manual_supports",
open_color=default_color,
)
self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu")
self.terminal_gizmo.color = default_color
self.terminal_gizmo.color_highlight = highlight_color
self.terminal_gizmo.use_draw_scale = False
self.terminal_gizmo.alpha = 0.8
self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type")
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
"""Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos.
- Lock glyphs: only WALL_MOUNTED_HANDRAIL while editing. Show
``lock_open`` when ``use_manual_supports`` is True, the closed
padlock when False ("auto-spacing is locked to support_spacing").
- Terminal gizmo: same gating, positioned just past the right rail
end so it reads as "configure the rail's end cap".
"""
super()._refresh_element_specific(context, mw, props)
# ``draw_prepare`` can fire on a freshly recreated GizmoGroup instance
# before ``setup_element_specific_gizmos`` has populated the lock /
# terminal attributes (Blender 5.x recreates per-region groups on
# reload). Bail out cheaply; the next refresh after setup completes
# will reposition them correctly.
if not hasattr(self, "lock_open_gizmo"):
return
# Single gate for all WALL_MOUNTED_HANDRAIL extras.
active = props.is_editing and not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL"
if not active:
self.lock_open_gizmo.hide = True
self.lock_closed_gizmo.hide = True
self.terminal_gizmo.hide = True
return
billboard_rot = self._frame_billboard_rot
view_rotation = self.schematic_view_rotation
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
# ── Lock glyphs for use_manual_supports ──────────────────────────
# Sit just above the wall's bottom line, near the centre of the
# schematic — visually grouped with the dimension it controls
# (support_spacing) without overlapping the arrow tail below.
is_manual = bool(props.use_manual_supports)
self.lock_open_gizmo.hide = not is_manual
self.lock_closed_gizmo.hide = is_manual
lock_local = Vector((0.0, 0.05, 0.0))
lock_world = anchor + billboard_rot @ view_rotation @ lock_local
lock_matrix = gizmo.billboarded_at(lock_world, billboard_rot, 0.09)
self.lock_open_gizmo.matrix_basis = lock_matrix
self.lock_closed_gizmo.matrix_basis = lock_matrix
# ── Terminal-type popup gizmo at the right rail end ──────────────
# Pushed well past the right wall edge so the icon doesn't crowd
# the wall outline or the bracket attach point. At rail height and
# rail depth so it reads as "attached to the rail terminal".
self.terminal_gizmo.hide = False
terminal_local = Vector(
(
self.SCHEMATIC_MESH_WIDTH_FRAC / 2 + 0.25,
self.SCHEMATIC_MESH_RAIL_Y_FRAC,
self.schematic_rail_clear(),
)
)
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
def update_editing_gizmos(
self, context: bpy.types.Context, mw: "Matrix", props: "prop.BIMRailingProperties"
) -> None:
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
The base class shows the pen gizmo whenever ``is_editing`` is False,
which is the case during path-edit too. Allowing the user to click
through into parametric edit while the polyline mesh is open in EDIT
mode mixes two distinct editing states and leaves a stale draft if
they cancel out block the entry point instead. The operator itself
is intentionally not guarded (callers via scripting can still invoke
it); this is the UX-level enforcement.
The cycle icon defaults to the editing icon row (next to validate /
cancel) via the parent's positioning. We move it to just above the
schematic mesh so it reads as "cycle the railing type *shown here*"
associated with the preview the user is interacting with, not a
generic editing button at the bottom of the schematic.
"""
super().update_editing_gizmos(context, mw, props)
if props.is_editing_path:
self.pen_gizmo.hide = True
if props.is_editing and not props.is_editing_path:
billboard_rot = self._frame_billboard_rot
view_rotation = self.schematic_view_rotation
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
# Comfortably above the mesh top edge so the icon doesn't crowd
# the ``thickness`` / ``clear_width`` dimension callouts that
# already sit just above the panel/wall.
cycle_local = Vector((0.0, self.SCHEMATIC_MESH_HEIGHT_FRAC + 0.25, 0.0))
world_pos = anchor + billboard_rot @ view_rotation @ cycle_local
# 30% smaller than the editing-icon-row default (0.30 → 0.21):
# the cycle is a tertiary affordance compared to pen/validate/cancel.
self.cycle_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, 0.21)
@classmethod
def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh":
"""Build a wireframe preview of the railing in schematic-local coordinates.
FRAMELESS_PANEL renders as a box whose proportions track the bound
properties (height / thickness / spacing); WALL_MOUNTED_HANDRAIL
renders as a horizontal tube with two L-shaped supports whose
proportions track railing_diameter / clear_width / support_spacing.
Both are scaled to fit inside ``[-schematic_box_size, +schematic_box_size]``
on each axis so the schematic reads the same regardless of absolute
property values.
The mesh is decorative clicks land on the labeled sliders, not on
the preview geometry. See ``BaseSchematicGizmoGroup`` for the
draw-handler lifecycle.
"""
bm = bmesh.new()
if props.railing_type == "FRAMELESS_PANEL":
cls._build_frameless_panel_schematic(bm, props)
else:
cls._build_wall_mounted_handrail_schematic(bm, props)
return bm
# Schematic-local half-width of the visible gap between the two panel boxes.
# Conveys the "spacing" semantic at a glance — the user sees two pickets
# separated by air, with the spacing dimension emerging from that gap.
SCHEMATIC_MESH_GAP_HALF_WIDTH = 0.05
@classmethod
def _build_frameless_panel_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
"""Stylised panel: two wireframe boxes with a visible gap between them.
The box edges sit at the ``SCHEMATIC_MESH_*_FRAC`` positions
(matching where the dimension gizmos anchor), so each dimension line
visually starts at the geometry feature it measures. Internal
proportions are stable across drags the actual values are shown
through the dimension labels, while the schematic communicates
which feature each label refers to. The gap between the two boxes
(set by ``SCHEMATIC_MESH_GAP_HALF_WIDTH``) gives the "spacing"
dimension a real visual referent.
Edges are tagged on a string layer so hover-highlight can colour
the geometric feature being measured: vertical edges height,
depth edges thickness. The X-aligned edges along the panel
width are untagged (they don't correspond to a single dimension).
"""
hw = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
hd = cls.SCHEMATIC_MESH_DEPTH_FRAC / 2
h_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
gap = cls.SCHEMATIC_MESH_GAP_HALF_WIDTH
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
# Edge index → feature tag for one box. Order matches the (a, b)
# tuple order below: bottom ring (4) + top ring (4) + verticals (4).
edge_tags_per_box = (
b"", # (0,1) bottom-back, X-aligned
b"panel_thickness", # (1,2) bottom-right, Z-aligned
b"", # (2,3) bottom-front, X-aligned
b"panel_thickness", # (3,0) bottom-left, Z-aligned
b"", # (4,5) top-back, X-aligned
b"panel_thickness", # (5,6) top-right, Z-aligned
b"", # (6,7) top-front, X-aligned
b"panel_thickness", # (7,4) top-left, Z-aligned
b"panel_height", # (0,4) vertical back-left
b"panel_height", # (1,5) vertical back-right
b"panel_height", # (2,6) vertical front-right
b"panel_height", # (3,7) vertical front-left
)
# Build two separate wireframe boxes — one on each side of the central
# gap. The boxes share the same Y range (0..h_top) and Z range (±hd)
# but split the X range so the gap from -gap to +gap stays empty.
for x_left, x_right in ((-hw, -gap), (gap, hw)):
corners = [
bm.verts.new((x_left, 0.0, -hd)),
bm.verts.new((x_right, 0.0, -hd)),
bm.verts.new((x_right, 0.0, hd)),
bm.verts.new((x_left, 0.0, hd)),
bm.verts.new((x_left, h_top, -hd)),
bm.verts.new((x_right, h_top, -hd)),
bm.verts.new((x_right, h_top, hd)),
bm.verts.new((x_left, h_top, hd)),
]
for tag, (a, b) in zip(
edge_tags_per_box,
(
(0, 1),
(1, 2),
(2, 3),
(3, 0), # bottom ring
(4, 5),
(5, 6),
(6, 7),
(7, 4), # top ring
(0, 4),
(1, 5),
(2, 6),
(3, 7), # vertical edges
),
):
edge = bm.edges.new((corners[a], corners[b]))
if tag:
edge[feat_layer] = tag
@classmethod
def _build_wall_mounted_handrail_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
"""Stylised wall-mounted handrail: wall outline, hex tube, two L-brackets.
Three visual elements convey "rail mounted on a wall":
- **Wall outline** a wireframe rectangle in the YZ plane at ``z=0``,
extending slightly past the rail ends so the wall reads as a
surface the rail is *attached to* rather than a coincident frame.
- **Handrail tube** a hexagonal cross-section extruded along ±X
at ``z=+clear_s`` (in front of the wall), at ``y=rail_y``.
- **L-shaped brackets** at each rail end from the rail centreline
drop a short distance, then run perpendicular back to the wall
plane. Mirrors the standard wall-mount bracket geometry: a
horizontal arm holding the rail off the wall, a vertical drop
attaching to the rail.
Like ``_build_frameless_panel_schematic``, the schematic uses fixed
proportions so the dimension gizmos' anchor points stay aligned
with the geometry features regardless of property values.
"""
half_len = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
wall_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
rail_y = cls.SCHEMATIC_MESH_RAIL_Y_FRAC # rail sits at half wall height
radius_s = cls.schematic_rail_radius()
clear_s = cls.schematic_rail_clear()
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
# ── Wall outline (rectangle at z=0, slightly wider than the rail) ──
# Spans the full schematic height; the rail attaches in the middle,
# so the wall reads as "continuing past the rail above and below".
# Wall edges stay untagged — they're background context, not a
# feature any dimension measures.
wall_extra = 0.08
wall_x_left = -half_len - wall_extra
wall_x_right = half_len + wall_extra
wall_corners = [
bm.verts.new((wall_x_left, 0.0, 0.0)),
bm.verts.new((wall_x_right, 0.0, 0.0)),
bm.verts.new((wall_x_right, wall_top, 0.0)),
bm.verts.new((wall_x_left, wall_top, 0.0)),
]
for a, b in ((0, 1), (1, 2), (2, 3), (3, 0)):
bm.edges.new((wall_corners[a], wall_corners[b]))
# ── Handrail tube (hex cross-section in YZ, extruded along X) ──────
# Centred on the rail centreline at (±(half_len - rail_inset),
# rail_y, +clear_s) — in front of the wall plane at z=0. The tube
# is shorter than the wall so the wall visibly extends past it on
# both sides; the L-brackets sit at the tube ends, so the leftmost
# bracket no longer coincides with the wall's left edge.
rail_inset = cls.SCHEMATIC_RAIL_INSET_FRAC
rail_x_left = -half_len + rail_inset
rail_x_right = half_len - rail_inset
segments = 6
ring_left, ring_right = [], []
for i in range(segments):
theta = 2 * math.pi * i / segments
dy = math.cos(theta) * radius_s
dz = math.sin(theta) * radius_s
ring_left.append(bm.verts.new((rail_x_left, rail_y + dy, clear_s + dz)))
ring_right.append(bm.verts.new((rail_x_right, rail_y + dy, clear_s + dz)))
# All hex-tube edges tagged "rail_tube" so they highlight together
# when the railing_diameter dimension is hovered.
for i in range(segments):
j = (i + 1) % segments
e_left = bm.edges.new((ring_left[i], ring_left[j]))
e_right = bm.edges.new((ring_right[i], ring_right[j]))
e_axial = bm.edges.new((ring_left[i], ring_right[i]))
e_left[feat_layer] = b"rail_tube"
e_right[feat_layer] = b"rail_tube"
e_axial[feat_layer] = b"rail_tube"
# ── L-brackets at each rail end (rail → drop → wall) ───────────────
# Bracket attach points follow the rail ends, so they're pulled
# inward by ``rail_inset`` from the wall edges. From the rail
# centreline, drop ``bracket_drop`` in Y, then run perpendicular
# back to the wall plane (z=0). The L shape reads as a wall-mount
# bracket under the 3/4 tilt. Both bracket segments tagged
# "bracket" so they highlight when clear_width OR support_spacing
# is hovered (both dimensions measure features of the supports).
bracket_drop = 0.06
for x in (rail_x_left, rail_x_right):
v_rail = bm.verts.new((x, rail_y, clear_s))
v_corner = bm.verts.new((x, rail_y - bracket_drop, clear_s))
v_wall = bm.verts.new((x, rail_y - bracket_drop, 0.0))
e1 = bm.edges.new((v_rail, v_corner))
e2 = bm.edges.new((v_corner, v_wall))
e1[feat_layer] = b"bracket"
e2[feat_layer] = b"bracket"
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.flip_railing_path_order"
bl_label = "Flip Railing Path Order"
@@ -1139,16 +510,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)
+1 -82
View File
@@ -271,7 +271,7 @@ class DumbSlabPlaner:
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
existing_x_angle = 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
@@ -508,7 +508,6 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
converter.run()
profile = tool.Ifc.get().createIfcArbitraryClosedProfileDef("AREA")
curve = None
for path in converter.paths:
points = []
lines = path[0]
@@ -518,7 +517,6 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
points.append(tool.Ifc.get().createIfcCartesianPoint(local_point))
points.append(points[0])
curve = tool.Ifc.get().createIfcPolyline(points)
assert curve
profile.OuterCurve = curve
old_profile = extrusion.SweptArea
@@ -622,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")
@@ -1001,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"}
+1 -1
View File
@@ -22,9 +22,9 @@ from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell.util.unit
from bpy.types import Panel
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
+96 -488
View File
@@ -48,7 +48,6 @@ import mathutils.geometry
import numpy as np
from mathutils import Matrix, Vector
import bonsai.core.connection
import bonsai.core.geometry
import bonsai.core.model as core
import bonsai.core.root
@@ -63,6 +62,7 @@ from bonsai.bim.module.model.decorator import (
_BBOX_HIGHLIGHT_LINE_WIDTH,
PolylineDecorator,
ProductDecorator,
_fill_quads_alpha,
bbox_world_edges,
draw_polyline_segments,
)
@@ -108,50 +108,6 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
return True
def _resolve_active_partner_pair(
context: bpy.types.Context,
) -> "tuple[bpy.types.Object, bpy.types.Object, ifcopenshell.entity_instance, ifcopenshell.entity_instance] | None":
"""Return ``(active_obj, partner_obj, active_elem, partner_elem)`` for a
selection of exactly two IFC-bound objects with the active one named,
else ``None``. Used by every 2-selection gizmo to skip the standard
"resolve active + partner + IFC entities" preamble."""
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return None
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
partner = next((o for o in selected if o != active), None)
if partner is None:
return None
active_elem = tool.Ifc.get_entity(active)
partner_elem = tool.Ifc.get_entity(partner)
if active_elem is None or partner_elem is None:
return None
return active, partner, active_elem, partner_elem
def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_editing: bool = False) -> bool:
"""Shared gate for slab-side connection gizmos: exactly 1 IfcSlab
selected, not an array child, has at least one wall clipped to its
underside. With ``require_editing=True`` additionally requires the
slab's parametric edit lifecycle to be active (pen icon clicked) so
the gizmo only surfaces after explicit opt-in."""
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None or not element.is_a("IfcSlab"):
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
if require_editing and not tool.Model.get_slab_props(active).is_editing:
return False
return any(tool.Wall.iter_slab_wall_connections(element))
def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter.
@@ -290,15 +246,6 @@ def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> N
_maybe_resync_wall_props_from_ifc(obj)
def _regenerate_walls(objs: "Iterable[bpy.types.Object | None]") -> None:
"""Rebuild every wall in ``objs`` from current IFC state — extrusion,
openings, and any underside slab clip so the caller doesn't carry
feature-specific dispatch."""
for obj in objs:
if obj is not None:
tool.Model.regenerate_wall(obj)
class _CommitWallDraftsFirstMixin:
"""Operator mixin that flushes any in-progress wall parametric drafts in
the current selection before delegating to the subclass's ``_perform``.
@@ -338,29 +285,19 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
"""Disconnect two IFC elements given their GlobalIds — generic dispatcher
that infers the connection rel kind via tool.Connection.find_rels and runs
the right post-disconnect cleanup:
class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
"""Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one
specific partner wall, leaving the active wall's other connections intact. The
partner is identified by IFC GlobalId invariant under Blender-object renames,
file save/reload, and the undo stack set on the operator properties by the
single-wall unjoin gizmo at click time."""
- ``"path"`` (IfcRelConnectsPathElements) removes every rel between
the pair (catches both orientations) via remove_connection + recreates
both walls + resyncs drafts.
- ``"element-top"`` (IfcRelConnectsElements with Description=="TOP")
disconnect_element + regenerate_wall_to_underside on the wall side.
- ``"element"`` (other IfcRelConnectsElements) disconnect_element only.
Both endpoints by GlobalId so the dispatch survives rename / undo / save.
Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab
operators with one entry-point gizmos and shortcuts can bind to."""
bl_idname = "bim.disconnect_elements"
bl_label = "Disconnect Elements"
bl_description = "Remove the connection between two IFC elements identified by GlobalId"
bl_idname = "bim.unjoin_wall_path_connection"
bl_label = "Unjoin Wall Connection"
bl_description = "Disconnect the active wall from a single specific partner wall"
bl_options = {"REGISTER", "UNDO"}
element_a_guid: bpy.props.StringProperty(name="Element A GlobalId")
element_b_guid: bpy.props.StringProperty(name="Element B GlobalId")
other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId")
@classmethod
def poll(cls, context):
@@ -372,54 +309,44 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I
return True
def _perform(self, context):
ifc_file = tool.Ifc.get()
try:
elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None
elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None
except RuntimeError:
elem_a = elem_b = None
if elem_a is None or elem_b is None:
self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.")
active = tool.Blender.get_active_object(is_selected=True)
if not active:
self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
return
rels = tool.Connection.find_rels(elem_a, elem_b)
if not rels:
self.report({"ERROR"}, "No connection found between elements.")
elem_active = tool.Ifc.get_entity(active)
if not elem_active:
self.report({"ERROR"}, "Active object is not bound to an IFC entity.")
return
# The fillet corner's join with its source walls defines the fillet's
# identity — unjoining there would tear down the chord axis reference
# without rebuilding the source walls' miter cuts. Deleting the corner
# wall is the supported teardown, which cascades back to the source
# walls via the connection-cleanup handler.
either_is_fillet = tool.Parametric.is_fillet_corner_wall(elem_a) or tool.Parametric.is_fillet_corner_wall(
elem_b
)
if either_is_fillet and any(k == "path" for _, k in rels):
self.report(
{"INFO"},
"Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.",
)
elem_other = None
if self.other_wall_guid:
try:
elem_other = tool.Ifc.get().by_guid(self.other_wall_guid)
except RuntimeError:
elem_other = None
other = tool.Ifc.get_object(elem_other) if elem_other else None
if not elem_other or not other:
self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
return
path_objs: list[bpy.types.Object] = []
for subject, kind in rels:
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
subject=subject,
kind=kind,
elem=elem_a,
partner=elem_b,
)
if kind == "path":
obj_a = tool.Ifc.get_object(elem_a)
obj_b = tool.Ifc.get_object(elem_b)
if obj_a is not None and obj_a not in path_objs:
path_objs.append(obj_a)
if obj_b is not None and obj_b not in path_objs:
path_objs.append(obj_b)
if path_objs:
_resync_walls_after_mutation(path_objs)
# Walk the inverse graph for the specific IfcRelConnectsPathElements joining
# these two walls and remove only that one. `disconnect_path`'s
# (relating, related) mode only inspects `relating.ConnectedTo`, so a single
# call misses the rel when it was authored with the opposite orientation.
rels = [
rel
for rel in getattr(elem_active, "ConnectedTo", [])
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other
] + [
rel
for rel in getattr(elem_active, "ConnectedFrom", [])
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other
]
for rel in rels:
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
# Recreate body+axis on both walls so the mesh state matches the IFC mutation
# and stale miter cuts are dropped.
tool.Model.recreate_wall(elem_active, active)
tool.Model.recreate_wall(elem_other, other)
_resync_walls_after_mutation([active, other])
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
@@ -442,7 +369,7 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to
element = tool.Ifc.get_entity(obj)
if not element:
continue
if tool.Parametric.is_path_connectable_wall(element):
if tool.Model.get_usage_type(element) == "LAYER2":
walls.append(obj)
else:
slabs.append(obj)
@@ -463,7 +390,7 @@ class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator):
wall_objs = [
obj
for obj in tool.Blender.get_selected_objects()
if (element := tool.Ifc.get_entity(obj)) and tool.Parametric.is_path_connectable_wall(element)
if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2"
]
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
@@ -715,14 +642,9 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
def _perform(self, context):
selected_objs = tool.Model.get_selected_mesh_objects()
post_split_walls: list[bpy.types.Object] = []
for obj in selected_objs:
new_obj = DumbWallJoiner().split(obj, context.scene.cursor.location)
post_split_walls.append(obj)
if new_obj is not None and new_obj not in post_split_walls:
post_split_walls.append(new_obj)
_resync_walls_after_mutation(post_split_walls)
_regenerate_walls(post_split_walls)
DumbWallJoiner().split(obj, context.scene.cursor.location)
_resync_walls_after_mutation(selected_objs)
return {"FINISHED"}
@@ -752,13 +674,11 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
active_obj = context.active_object
assert active_obj
selected_objs = tool.Model.get_selected_mesh_objects()
# Active-is-survivor — matches Blender's Ctrl+J / "merge at last"
# convention. The first argument survives, the second is consumed,
# so the active wall ends up absorbing the other.
other_obj = next(o for o in selected_objs if o != active_obj)
DumbWallJoiner().merge(active_obj, other_obj)
_maybe_resync_wall_props_from_ifc(active_obj)
_regenerate_walls([active_obj])
# The merge deletes the second argument when the walls are collinear;
# only the first survives, so the resync targets the non-active wall.
surviving_obj = next(o for o in selected_objs if o != active_obj)
DumbWallJoiner().merge(surviving_obj, active_obj)
_maybe_resync_wall_props_from_ifc(surviving_obj)
return {"FINISHED"}
@@ -825,7 +745,6 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
_resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
@@ -872,7 +791,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
extrusion.Depth = perpendicular_depth
else:
if tool.Model.get_usage_type(element) == "LAYER3":
existing_x_angle = 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
@@ -941,7 +860,6 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
_resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
@@ -964,7 +882,6 @@ class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator):
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
for obj in selected_objs:
joiner.set_length(obj, self.length)
_resync_walls_after_mutation(selected_objs)
class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator):
@@ -1546,7 +1463,7 @@ class DumbWallJoiner:
body = copy.deepcopy(axis1["reference"])
tool.Model.recreate_wall(element1, wall1)
def split(self, wall1: bpy.types.Object, target: Vector) -> "bpy.types.Object | None":
def split(self, wall1: bpy.types.Object, target: Vector) -> None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
element1 = tool.Ifc.get_entity(wall1)
@@ -1567,17 +1484,9 @@ class DumbWallJoiner:
wall2 = self.duplicate_wall(wall1)
element2 = tool.Ifc.get_entity(wall2)
# The duplicate inherits wall1's slab-trim boolean chain (copied by
# copy_class) but ``BBIM_Boolean.Data`` carries wall1's stale ids, so
# ``get_manual_booleans(element2)`` returns empty and the regenerator
# rebuilds wall2's body without those clips. Strip them up front so
# wall2 starts clean before the axis + placement reshape.
tool.Model.strip_underside_booleans(element2)
# Get the ATEND connection from wall1 to use it in wall2
relating_element = None
connections = element1.ConnectedTo
relating_connection, description = ..., ...
for conn in connections:
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
relating_element = conn.RelatedElement
@@ -1592,7 +1501,6 @@ class DumbWallJoiner:
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
if relating_element:
assert relating_connection is not ... and description is not ...
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=relating_element,
@@ -1635,16 +1543,13 @@ class DumbWallJoiner:
r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings
]:
rel = opening.HasFillings[0]
filling = rel.RelatedBuildingElement
filling_obj = tool.Ifc.get_object(filling)
filling_location = filling_obj.matrix_world.translation
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d)
min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
# Use the opening's axis-projected midpoint to classify the side.
# The filling's ``matrix_world.translation`` is flip-fragile —
# flipping rotates the filler 180° + translates so the bbox
# stays visually in place, moving the door origin to the
# opposite corner, which would mis-classify a flipped door
# centred over the cut.
opening_midpoint = (min_t + max_t) / 2
void_straddles = min_t < cut_percentage < max_t
if opening_midpoint > cut_percentage:
if filling_position > cut_percentage:
# The filling should be moved from element1 to element2.
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
new_opening.VoidsElements[0].RelatingBuildingElement = element2
@@ -1659,16 +1564,13 @@ class DumbWallJoiner:
rel.RelatingOpeningElement = new_opening
if void_straddles:
# Filling moved to element2, but void straddles — add a
# pure-void copy back to element1. Read from the original
# ``opening`` whose ObjectPlacement still references
# element1; ``new_opening`` was rebound to element2 and
# would copy element2's frame instead.
_add_void_copy(element1, opening)
# Remove the old opening
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
if void_straddles:
# Filling moved to element2, but void straddles — add a
# pure-void copy back to element1 so its body still gets cut.
_add_void_copy(element1, new_opening)
elif void_straddles:
# Filling stays on element1, but void straddles — add a pure-void
# copy to element2 so its body gets cut.
@@ -1681,7 +1583,6 @@ class DumbWallJoiner:
tool.Model.recreate_wall(element1, wall1)
tool.Model.recreate_wall(element2, wall2)
return wall2
def flip(self, wall1: bpy.types.Object) -> None:
if tool.Ifc.is_moved(wall1):
@@ -1737,14 +1638,7 @@ class DumbWallJoiner:
p2[0] = max(x_ordinates)
self.set_axis(element1, p1, p2)
# ConnectedTo / ConnectedFrom carry both ``IfcRelConnectsPathElements``
# (the wall-wall joins this loop migrates) and
# ``IfcRelConnectsElements`` (the slab underside clip). Only the
# path rels expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
# the element rels die with element2 via the trailing cascade delete.
for rel in element2.ConnectedTo:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType
)
@@ -1757,8 +1651,6 @@ class DumbWallJoiner:
)
for rel in element2.ConnectedFrom:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType
)
@@ -1770,26 +1662,6 @@ class DumbWallJoiner:
related_connection=rel.RelatedConnectionType,
)
# Re-host openings from the discarded wall to the survivor before
# the cascade delete tears down element2's voids and any filling
# that depends on them. ``edit_object_placement`` preserves the
# opening's world position when element1 and element2 have
# different placements — a ``PlacementRelTo`` swap alone would
# shift the opening as the relative offset changes.
ifc_file = tool.Ifc.get()
for rel in list(element2.HasOpenings):
opening = rel.RelatedOpeningElement
rel.RelatingBuildingElement = element1
if opening.ObjectPlacement:
world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=opening,
matrix=world_matrix,
is_si=False,
should_transform_children=False,
)
tool.Model.recreate_wall(element1, wall1)
tool.Geometry.delete_ifc_object(wall2)
@@ -2586,9 +2458,7 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator):
tool.Model,
context.scene.cursor.location,
)
affected = list(tool.Blender.get_selected_objects())
_resync_walls_after_mutation(affected)
_regenerate_walls(affected)
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
return {"FINISHED"}
@@ -2621,7 +2491,6 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
bpy.ops.bim.change_extrusion_depth(depth=new_height)
_maybe_resync_wall_props_from_ifc(obj)
_regenerate_walls([obj])
return {"FINISHED"}
@@ -3248,12 +3117,6 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp
# the banana body. If a neighbour moved, the new placement follows; if
# neither moved, the new matrix equals the old within floating-point noise.
_apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj)
# The body rebuild swaps the wall's representation, so any prior underside
# clip is gone. Re-clip from the surviving TOP rels so an extend-to-slab
# applied to a fillet wall isn't silently wiped on the next neighbour
# recalc, ChangeExtrusionDepth, or split / merge call site.
if tool.Model.has_underside_connection(element):
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [obj])
class EnableWallFilletPreview(bpy.types.Operator):
@@ -3373,19 +3236,6 @@ class CancelWallFilletPreview(bpy.types.Operator):
props = preview_base.get_preview_props(context, "wall_fillet")
if props is None or not props.is_active:
return {"CANCELLED"}
# Clear the corner's edit flag so the connection disconnect gizmos
# disappear in lockstep with the radius preview when the user
# cancels. The id read happens BEFORE clear_preview_state wipes it.
corner_id = props.editing_corner_id
if corner_id:
ifc_file = tool.Ifc.get()
if ifc_file is not None:
try:
corner_obj = tool.Ifc.get_object(ifc_file.by_id(corner_id))
except RuntimeError:
corner_obj = None
if corner_obj is not None:
tool.Model.get_wall_props(corner_obj).is_editing = False
preview_base.clear_preview_state(props)
return {"FINISHED"}
@@ -3459,11 +3309,6 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
props.radius = float(radius)
props.editing_corner_id = corner_elem.id()
props.is_active = True
# Flag the corner as "in edit mode" so the wall-side connection
# disconnect gizmos surface in parallel with the fillet preview —
# one pen-icon click enters BOTH radius retune AND connection
# inspection.
tool.Model.get_wall_props(corner_obj).is_editing = True
return {"FINISHED"}
@@ -3744,7 +3589,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
return False
other = next(o for o in selected if o is not active)
other_element = tool.Ifc.get_entity(other)
if not other_element or not tool.Parametric.is_path_connectable_wall(other_element):
if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2":
return False
return True
@@ -4010,17 +3855,15 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at
every connection location on the wall wall-wall path connections via
IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements
with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART
+ 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped
slab), so a pool of icons is preallocated and hidden on a per-frame basis based
on the live connection set.
every join location inferred from the wall's IfcRelConnectsPathElements inverse
graph the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall
unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND
by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated
and hidden on a per-frame basis based on the live connection set.
Each visible icon dispatches `bim.disconnect_elements` with the active wall +
partner element GlobalIds set on the bound operator properties, so a click
removes only the single rel under that icon the other connections on the
same wall survive.
Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner
wall's GlobalId set on the bound operator properties, so a click removes only
the single rel under that icon the other connections on the same wall survive.
Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group
requires len(selected) == 2; this one requires 1)."""
@@ -4038,25 +3881,10 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
# creation is forbidden — so the pool must be sized upfront for the worst case.
POOL_SIZE = 16
ICON_SCALE = 0.35
SLAB_STACK_MAX = 5
SLAB_STACK_OFFSET_Z = 0.5
# Muted gray used for connection icons that are visible (the connection
# exists) but inert (clicking dispatches a no-op + INFO report). Fillet
# corner ↔ source-wall joins use this — disconnecting them would tear
# down the fillet's chord axis reference, so the supported teardown is
# deleting the corner wall instead.
LOCKED_COLOR: ClassVar[tuple[float, float, float]] = (0.5, 0.5, 0.5)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
# Bypass the shared topology gate's ``any_preview_active`` block —
# ``BIMWallProperties.is_editing`` is the real gate for this gizmo
# group, and that flag is set both by the regular wall edit lifecycle
# AND by the fillet preview entry (so a fillet corner under preview
# surfaces its connections in parallel with the radius drag).
if not tool.Blender.are_viewport_gizmos_enabled():
return False
if tool.Blender.Modifier.any_selected_is_array_child():
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -4074,9 +3902,6 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
# Stashed so per-frame ``_bind_unjoin_icon`` can restore the active
# tone when an icon was muted in a previous frame for fillet lock.
self._default_unjoin_color = default_color
# Bind the operator on each pool icon ONCE at setup time and keep the returned
# OperatorProperties handles. target_set_operator allocates a fresh handle on
# every call, so calling it from position_gizmos (which fires every redraw
@@ -4086,11 +3911,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
self.unjoin_op_props = []
for _ in range(self.POOL_SIZE):
icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection"
)
icon.hide = True
self.unjoin_icons.append(icon)
self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection"))
def position_gizmos(self, context: bpy.types.Context) -> None:
# Default: hide every pool slot. The visible-set is rebuilt from the live
@@ -4111,28 +3936,15 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
path_connections = _get_wall_connections_cached(self, elem)
slab_connections = list(tool.Wall.iter_wall_slab_connections(elem))
slab_overflow = max(0, len(slab_connections) - self.SLAB_STACK_MAX)
if slab_overflow and not getattr(self, "_slab_cap_warned", False):
connections = _get_wall_connections_cached(self, elem)
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(slab_connections)} slab "
f"connections; only the first {self.SLAB_STACK_MAX} are shown stacked."
)
self._slab_cap_warned = True
slab_connections = slab_connections[: self.SLAB_STACK_MAX]
total = len(path_connections) + len(slab_connections)
if total > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {total} connections "
f"({len(path_connections)} path + {len(slab_connections)} slab); "
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; "
f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
)
self._pool_cap_warned = True
slot_idx = 0
self_is_fillet = tool.Parametric.is_fillet_corner_wall(elem)
for other_elem, self_ct, other_ct in path_connections:
for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections):
if slot_idx >= self.POOL_SIZE:
break
other_obj = tool.Ifc.get_object(other_elem)
@@ -4143,224 +3955,20 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
continue
seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom)
location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct)
is_locked = self_is_fillet or tool.Parametric.is_fillet_corner_wall(other_elem)
self._bind_unjoin_icon(
slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj, is_locked=is_locked
)
slot_idx += 1
for stack_idx, (slab_elem, _rel) in enumerate(slab_connections):
if slot_idx >= self.POOL_SIZE:
break
slab_obj = tool.Ifc.get_object(slab_elem)
if slab_obj is None:
continue
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
continue
# Stack vertically so each slab gets a distinct clickable icon;
# hover-highlight then shows the user which slab they're about to
# disconnect from.
stacked = location + Vector((0.0, 0.0, stack_idx * self.SLAB_STACK_OFFSET_Z))
self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj)
slot_idx += 1
def _bind_unjoin_icon(
self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj, *, is_locked=False
):
"""Place + bind one pool icon to a (active, partner) GlobalId pair.
Only the GlobalId properties are rewritten per frame; the operator
binding itself is the long-lived handle set up at setup() time. GlobalId
(not Blender object name) keeps the binding stable across renames, file
save/reload, and any sit-in-the-undo-stack interlude between dispatch
and execute. The partner Blender object is mirrored onto the icon for
its hover-outline draw, since the Gizmo API exposes
``target_set_operator`` but no symmetric reader.
``is_locked=True`` (fillet corner involvement) writes a muted color
instead of the active tone; the GUIDs still propagate so the bound
operator can surface a friendly INFO report on click."""
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
icon.color = self.LOCKED_COLOR if is_locked else self._default_unjoin_color
self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId
self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId
icon.partner_obj = partner_obj
class GizmoSlabUnjoinWalls(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Slab-side mirror of GizmoWallUnjoinSingle: when exactly one IfcSlab is
selected and at least one wall is clipped to its underside, surface an
unjoin icon at each connection point. The icons resolve at the same
world location as the wall-side gizmo (via the symmetric
tool.Wall.wall_slab_connection_location_world) so the same connection
has a single visual marker reachable from either selection.
Each visible icon dispatches bim.disconnect_elements with the slab +
wall GlobalIds, so a click removes the single rel under that icon and
re-clips the wall to whatever remaining slabs it's connected to."""
bl_idname = "OBJECT_GGT_bim_slab_unjoin_walls"
bl_label = "Slab Unjoin Walls Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
POOL_SIZE = 16
ICON_SCALE = 0.35
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return _slab_connection_gizmo_poll_gate(context, require_editing=True)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.unjoin_icons = []
self.unjoin_op_props = []
for _ in range(self.POOL_SIZE):
icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
icon.hide = True
self.unjoin_icons.append(icon)
self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
def position_gizmos(self, context: bpy.types.Context) -> None:
for icon in self.unjoin_icons:
icon.hide = True
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
return
slab_obj = selected[0]
slab_elem = tool.Ifc.get_entity(slab_obj)
if slab_elem is None:
return
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = list(tool.Wall.iter_slab_wall_connections(slab_elem))
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoSlabUnjoinWalls: slab has {len(connections)} wall connections; "
f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
)
self._pool_cap_warned = True
slot_idx = 0
for wall_elem, _rel in connections:
if slot_idx >= self.POOL_SIZE:
break
wall_obj = tool.Ifc.get_object(wall_elem)
if wall_obj is None:
continue
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
continue
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId
self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId
icon.partner_obj = wall_obj
slot_idx += 1
class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"""Pen / validate / cancel triad for slab disconnect-access mode.
Polls on a single IfcSlab with at least one wall clipped to its underside.
Pen routes through the universal ``bim.enable_editing_parametric``
dispatcher; finish + cancel both clear ``is_editing`` (no IFC mutation
the framework requires the triad to exist by name convention even for a
pure UI gate). ESC, the red-coloured cancel icon, mutual exclusion with
other active parametric edits, gizmo prefs gating all handled by the
base class."""
bl_idname = "OBJECT_GGT_bim_slab_edition"
bl_label = "Slab Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_slab"
finish_editing_operator = "bim.finish_editing_slab"
cancel_editing_operator = "bim.cancel_editing_slab"
cycle_type_operator = ""
props_getter = tool.Model.get_slab_props
gizmo_pref_name = "slab"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_slab(element) and any(tool.Wall.iter_slab_wall_connections(element))
class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Surfaces a disconnect icon when exactly 2 IFC elements are selected
and they share a supported rel currently the wall + slab pair joined
by an ``IfcRelConnectsElements(TOP)``. Click dispatches
``bim.disconnect_elements`` with both GlobalIds. For wall-wall pairs,
``GizmoWallJoinIntersection``'s unjoin icon already exposes the same
affordance via ``bim.unjoin_walls``."""
bl_idname = "OBJECT_GGT_bim_pair_disconnect"
bl_label = "Disconnect Pair Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE = 0.35
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
elem_a = tool.Ifc.get_entity(selected[0])
elem_b = tool.Ifc.get_entity(selected[1])
if elem_a is None or elem_b is None:
return False
rels = tool.Connection.find_rels(elem_a, elem_b)
return any(kind == "element-top" for _, kind in rels)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.disconnect_icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
self.disconnect_icon.hide = True
self.disconnect_op = self.disconnect_icon.target_set_operator("bim.disconnect_elements")
def position_gizmos(self, context: bpy.types.Context) -> None:
self.disconnect_icon.hide = True
pair = _resolve_active_partner_pair(context)
if pair is None:
return
active, partner_obj, active_elem, partner_elem = pair
# Helper expects wall + slab regardless of which the user marked active.
if active_elem.is_a("IfcWall"):
wall_obj, slab_obj = active, partner_obj
elif partner_elem.is_a("IfcWall"):
wall_obj, slab_obj = partner_obj, active
else:
return
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
return
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
self.disconnect_icon.matrix_basis = gizmo.billboarded_at(
location + clearance, billboard_rot, scale=self.ICON_SCALE
)
self.disconnect_icon.hide = False
self.disconnect_op.element_a_guid = active_elem.GlobalId
self.disconnect_op.element_b_guid = partner_elem.GlobalId
self.disconnect_icon.partner_obj = partner_obj
# Only the partner-GlobalId property is rewritten per frame; the operator
# binding itself is the long-lived handle set up at setup() time. GlobalId
# (not Blender object name) keeps the binding stable across renames, file
# save/reload, and any sit-in-the-undo-stack interlude between dispatch
# and execute.
self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId
# Mirror the partner reference onto the icon itself so its draw()
# can outline the partner on hover without a Gizmo-side getter on
# the bound operator (the API exposes target_set_operator with
# no symmetric reader).
icon.partner_obj = other_obj
class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
@@ -4860,7 +4468,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
],
color_rgb: tuple[float, float, float],
) -> None:
tool.Blender.draw_quads(context, quads, fill_color=(*color_rgb, self.QUAD_ALPHA))
_fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA)
@staticmethod
def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[
@@ -963,11 +963,7 @@ class EditObjectUI:
@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"):
if AuthoringData.data["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)
@@ -1321,7 +1317,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):
+64 -7
View File
@@ -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,
)
+3 -60
View File
@@ -18,12 +18,11 @@
import json
from pathlib import Path
from typing import TYPE_CHECKING
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
@@ -122,8 +100,8 @@ class ExecuteIfcPatch(bpy.types.Operator):
if props.should_load_from_memory and tool.Ifc.get():
args["file"] = tool.Ifc.get()
else:
args["input"] = props.ifc_patch_input
args["file"] = ifcopenshell.open(props.ifc_patch_input)
args["input"] = cast(str, props.ifc_patch_input)
args["file"] = cast(ifcopenshell.file, ifcopenshell.open(props.ifc_patch_input))
# Store this in case the patch recipe resets the Blender session, such as by loading a new project.
ifc_patch_output = props.ifc_patch_output or props.ifc_patch_input
@@ -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):
-19
View File
@@ -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)
@@ -18,8 +18,6 @@
import bpy
import bonsai.tool as tool
from . import decorator, gizmo, operator, prop, ui, workspace
classes = (
@@ -32,9 +30,7 @@ classes = (
operator.BIM_FH_import_ifc,
operator.BIM_OT_apply_pending_opening_cuts,
operator.BIM_OT_dismiss_multi_instance_warning,
operator.BIM_OT_dismiss_pending_array_repair,
operator.BIM_OT_dismiss_pending_opening_cuts,
operator.BIM_OT_select_pending_array_repair,
operator.BIM_OT_select_pending_opening_cuts,
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
@@ -60,8 +56,6 @@ classes = (
operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink,
operator.AutosavePrompt,
operator.LoadAutosavedRecoveryPopup,
operator.LoadLinkedProject,
operator.LoadProject,
operator.LoadProjectElements,
@@ -92,7 +86,6 @@ classes = (
prop.FilterCategory,
prop.Link,
prop.EditedObj,
prop.PendingArrayRepair,
prop.PendingOpeningRecut,
prop.BIMProjectProperties,
prop.MeasureToolSettings,
@@ -140,7 +133,6 @@ def register():
def unregister():
if not bpy.app.background:
bpy.utils.unregister_tool(workspace.ExploreTool)
tool.Autosave.cancel_timer()
del bpy.types.Scene.BIMProjectProperties
del bpy.types.Scene.MeasureToolSettings
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
+2 -2
View File
@@ -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,9 +110,7 @@ class ProjectDecorator:
if geom.selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
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:
@@ -136,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")
@@ -196,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)
@@ -281,9 +281,9 @@ class RefreshLibrary(bpy.types.Operator):
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
root_context = tool.Project.get_root_context(library_file)
ifc_project = library_file.by_type("IfcProject")[0]
hierarchy = tool.Project.get_project_hierarchy(library_file)
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy)
return {"FINISHED"}
@@ -714,8 +714,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
representations = element.RepresentationMaps or []
elif element.is_a("IfcProduct"):
representations = [element.Representation] if element.Representation else []
else:
assert False, element
for representation in representations or []:
for element in self.file.traverse(representation):
if not element.is_a("IfcRepresentationItem") or not element.StyledByItem:
@@ -765,10 +763,7 @@ class EditProjectLibrary(bpy.types.Operator):
previous_parent_library = tool.Project.get_parent_library(project_library)
new_parent_library = library_file.by_id(int(props.parent_library))
if previous_parent_library != new_parent_library:
if previous_parent_library is None:
# Edited library was a root in a library-only file; nest it under the new parent.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
elif previous_parent_library.is_a("IfcProject"):
if previous_parent_library.is_a("IfcProject"):
# Then new one is IfcProjectLibrary.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
else: # Previous is IfcProjectLibrary.
@@ -809,12 +804,9 @@ class AddProjectLibrary(bpy.types.Operator):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
root_context = tool.Project.get_root_context(library_file)
project = library_file.by_type("IfcProject")[0]
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
if root_context.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
ifcopenshell.api.project.assign_declaration(library_file, [project_library], project)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
@@ -987,10 +979,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
if TYPE_CHECKING:
filepath: str
@@ -999,7 +989,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
use_relative_path: bool
should_start_fresh_session: bool
import_without_ifc_data: bool
skip_autosave_recovery: bool
use_detailed_tooltip: bool
@classmethod
@@ -1046,33 +1035,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
return tooltip
def check_autosave_recovery(self, context: bpy.types.Context) -> bool:
if self.skip_autosave_recovery:
return False
autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs())
if not autosaved_filepath:
return False
# Fire-and-forget: don't propagate this popup's own RUNNING_MODAL
# return value up as if *this* operator were running modally too -
# we never call modal_handler_add() on ourselves, so the window
# manager would be left tracking a modal operator with no handler,
# corrupting its operator bookkeeping until it crashes later when
# the (real) popup modal handler is closed.
bpy.ops.bim.load_autosaved_recovery_popup(
"INVOKE_DEFAULT",
original_filepath=str(self.get_filepath_abs()),
autosaved_filepath=autosaved_filepath,
is_advanced=self.is_advanced,
use_relative_path=self.use_relative_path,
should_start_fresh_session=self.should_start_fresh_session,
import_without_ifc_data=self.import_without_ifc_data,
)
return True
def execute(self, context):
if self.check_autosave_recovery(context):
return {"FINISHED"}
if (
tool.Blender.get_addon_preferences().save_metadata_blend_file
and self.should_start_fresh_session
@@ -1150,14 +1113,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.",
)
return {"CANCELLED"}
if not tool.Ifc.get().by_type("IfcProject"):
self.report(
{"ERROR"},
"This file contains no IfcProject. It is likely an IFC project library — "
"load it via Project Setup → Project Library → Select Library File instead.",
)
IfcStore.purge()
return {"CANCELLED"}
props = tool.Project.get_project_props()
props.is_loading = True
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
@@ -1167,8 +1122,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
props.should_save_metadata_for_this_file = metadata_doc is not None
tool.Blender.register_toolbar()
if not self.skip_recent:
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
if self.is_advanced:
pass
@@ -1181,13 +1135,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
except:
bonsai.last_error = traceback.format_exc()
raise
tool.Autosave.reset_timer()
return {"FINISHED"}
def invoke(self, context, event):
if self.filepath:
if self.check_autosave_recovery(context):
return {"FINISHED"}
return self.execute(context)
return ImportHelper.invoke(self, context, event)
@@ -1285,17 +1236,6 @@ class LoadProjectElements(bpy.types.Operator):
f"Apply manually from the Project panel.",
)
props.pending_array_repair.clear()
if ifc_importer.broken_arrays:
for element in ifc_importer.broken_arrays:
item = props.pending_array_repair.add()
item.ifc_definition_id = element.id()
self.report(
{"WARNING"},
f"{len(ifc_importer.broken_arrays)} array parent(s) reference missing child GUIDs. "
f"Inspect from the Project panel.",
)
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
@@ -1329,11 +1269,6 @@ class LoadProjectElements(bpy.types.Operator):
if element.IsDecomposedBy:
for subelement in element.IsDecomposedBy[0].RelatedObjects:
decomposed_elements.add(subelement)
# IfcSurfaceFeature (e.g. road markings) adhere to a host element
# via IfcRelAdheresToElement, a [1:1] hierarchical relationship in
# the same family as aggregation, containment and nesting (IFC4.3).
for rel in getattr(element, "HasSurfaceFeatures", ()):
decomposed_elements.update(rel.RelatedSurfaceFeatures)
if decomposed_elements:
self.append_decomposed_elements(decomposed_elements)
elements.update(decomposed_elements)
@@ -1462,7 +1397,6 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
new.query = self.query
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
@@ -1533,10 +1467,6 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
# Fall back to the Link's stored query so callers that omit it
# still replay the filter the link was created with.
if not self.query and self.link.query:
self.query = self.link.query
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
@@ -1704,36 +1634,13 @@ class ReloadLink(bpy.types.Operator):
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
query: bpy.props.StringProperty(
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
)
if TYPE_CHECKING:
link_index: int
query: str
def invoke(self, context, event):
link = tool.Project.get_project_props().links[self.link_index]
self.query = link.query
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
self.layout.prop(self, "query", placeholder="IfcElement")
def execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
# An unset query means the operator was called without the dialog
# (e.g. from a script) - preserve the link's stored query instead
# of overwriting it with the empty default.
if self.properties.is_property_set("query"):
link.query = self.query
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=link.query) or {"FINISHED"}
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -1982,7 +1889,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
if TYPE_CHECKING:
filter_glob: str
@@ -2031,7 +1937,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
project_props = tool.Project.get_project_props()
prefs = tool.Blender.get_addon_preferences()
project_props.use_relative_project_path = self.use_relative_path
old_history_size, old_undo_steps = None, None
if prefs.should_disable_undo_on_save:
old_history_size = tool.Ifc.get().history_size
old_undo_steps = context.preferences.edit.undo_steps
@@ -2039,24 +1944,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
context.preferences.edit.undo_steps = 0
IfcStore.execute_ifc_operator(self, context)
if prefs.should_disable_undo_on_save:
assert old_history_size is not None and old_undo_steps is not None
tool.Ifc.get().history_size = old_history_size
context.preferences.edit.undo_steps = old_undo_steps
return {"FINISHED"}
def _execute(self, context):
project_props = tool.Project.get_project_props()
project_props.use_relative_project_path = self.use_relative_path
# Fallback if filepath is not set
if not getattr(self, "filepath", None) or self.filepath.strip() in ("", ".ifc"):
props = tool.Blender.get_bim_props()
if props.ifc_file:
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file)))
else:
self.report({"ERROR"}, "No filepath available for saving.")
return {"CANCELLED"}
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Previews are session-transient — discard rather than commit. Sibling
# gizmo polls gate on each preview's is_active flag, and a stuck flag
@@ -2119,8 +2011,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start))
print("Export finished in {:.2f} seconds".format(time.time() - start))
# New project created in Bonsai should be in recent projects too.
if not self.skip_recent:
tool.Project.add_recent_ifc_project(Path(output_file))
tool.Project.add_recent_ifc_project(Path(output_file))
props = tool.Project.get_project_props()
if props.use_relative_project_path and bpy.data.is_saved:
output_file = os.path.relpath(output_file, bpy.path.abspath("//"))
@@ -2154,7 +2045,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
)
bonsai.bim.handler.refresh_ui_data()
tool.Autosave.reset_timer()
@classmethod
def description(cls, context, properties):
@@ -2163,123 +2053,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return "Save the IFC file. Will save both .IFC/.BLEND files if synced together"
class LoadAutosavedRecoveryPopup(bpy.types.Operator):
bl_idname = "bim.load_autosaved_recovery_popup"
bl_label = "Recover Autosaved File"
bl_options = {"REGISTER", "UNDO"}
original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
def draw(self, context):
layout = self.layout
layout.label(text="A newer autosaved copy was found:", icon="INFO")
layout.label(text=os.path.basename(self.autosaved_filepath))
layout.separator()
layout.label(text="Do you want to load the autosaved version instead?")
layout.label(text="(Cancel will load the original)")
def invoke(self, context, event):
# invoke_props_dialog is modal - unlike invoke_popup/popup_menu, it
# isn't dismissed by the mouse simply leaving its bounds. It always
# renders both a fixed "Cancel" button and this confirm_text one, so
# the question is framed as Yes/Cancel rather than adding separate
# Load buttons on top.
return context.window_manager.invoke_props_dialog(
self, width=420, title="Recover Autosaved File", confirm_text="Yes"
)
def _load_kwargs(self, filepath: str, skip_recent: bool) -> dict:
return dict(
filepath=filepath,
skip_autosave_recovery=True, # Prevent infinite loop
is_advanced=self.is_advanced,
use_relative_path=self.use_relative_path,
should_start_fresh_session=self.should_start_fresh_session,
import_without_ifc_data=self.import_without_ifc_data,
skip_recent=skip_recent,
)
@staticmethod
def _defer(callback) -> None:
def on_timer() -> None:
callback()
return None
# bim.load_project (with should_start_fresh_session, our default)
# calls wm.read_homefile(), which tears down the window
# manager/screens/regions. Calling that synchronously from this
# dialog's execute()/cancel() - themselves invoked from deep inside
# Blender's modal handling for this popup's button click - frees
# data that the still-on-stack caller dereferences once we return,
# segfaulting Blender. Deferring by one timer tick runs the reload
# after the popup's own modal handling has fully unwound. The
# callback only closes over plain values (not `self`), since the
# operator instance itself may no longer be valid by the time the
# timer fires.
bpy.app.timers.register(on_timer, first_interval=0.0)
def execute(self, context):
kwargs = self._load_kwargs(self.autosaved_filepath, skip_recent=True)
original_filepath = self.original_filepath
def load_and_repoint() -> None:
bpy.ops.bim.load_project(**kwargs)
# Re-point tracking at the original path so future saves write
# back to it, not "_autosaved.ifc".
tool.Ifc.set_path(original_filepath)
self._defer(load_and_repoint)
return {"FINISHED"}
def cancel(self, context):
# Also reached via Escape or a click outside the dialog, not just Cancel.
kwargs = self._load_kwargs(self.original_filepath, skip_recent=False)
self._defer(lambda: bpy.ops.bim.load_project(**kwargs))
class AutosavePrompt(bpy.types.Operator):
bl_idname = "bim.autosave_prompt"
bl_label = "Autosave Reminder"
bl_options = set()
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(
self, width=400, confirm_text="Save", title="Autosave Reminder"
)
def draw(self, context):
layout = self.layout
layout.label(text="The autosave timer has expired.", icon="INFO")
layout.label(text="Would you like to save your IFC project now?")
def execute(self, context):
# Get current IFC path
props = tool.Blender.get_bim_props()
current_ifc_path = props.ifc_file
if not current_ifc_path:
self.report({"WARNING"}, "No IFC file path set. Please save manually.")
tool.Autosave.reset_timer()
return {"CANCELLED"}
# Call save_project with explicit filepath using EXEC_DEFAULT
result = bpy.ops.bim.save_project(
"EXEC_DEFAULT", filepath=current_ifc_path, should_save_as=False, skip_recent=True
)
tool.Autosave.reset_timer()
return result
def cancel(self, context):
tool.Autosave.reset_timer()
return {"CANCELLED"}
class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_idname = "bim.load_linked_project"
bl_label = "Load Project For Viewing Only"
@@ -3766,44 +3539,3 @@ class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
return {"FINISHED"}
class BIM_OT_select_pending_array_repair(bpy.types.Operator):
bl_idname = "bim.select_pending_array_repair"
bl_label = "Select Array Parents With Missing Children"
bl_description = "Select the Blender objects of array parents whose BBIM_Array.Data references children that don't resolve in the file."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"INFO"}, "No IFC file loaded.")
return {"CANCELLED"}
objects: list[bpy.types.Object] = []
for item in tool.Project.get_project_props().pending_array_repair:
try:
element = ifc_file.by_id(item.ifc_definition_id)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
if obj is not None:
objects.append(obj)
if not objects:
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
return {"CANCELLED"}
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} array parent(s).")
return {"FINISHED"}
class BIM_OT_dismiss_pending_array_repair(bpy.types.Operator):
bl_idname = "bim.dismiss_pending_array_repair"
bl_label = "Dismiss Pending Array Repair"
bl_description = (
"Clear the pending array-repair list without acting on it. The underlying BBIM_Array.Data stays unchanged."
)
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
tool.Project.get_project_props().pending_array_repair.clear()
return {"FINISHED"}
+1 -21
View File
@@ -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,11 +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="Query",
description="Selector query used to filter elements when loading the linked model",
default="",
)
if TYPE_CHECKING:
name: str
@@ -280,7 +274,6 @@ class Link(PropertyGroup):
include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
query: str
class EditedObj(PropertyGroup):
@@ -313,17 +306,6 @@ class PendingOpeningRecut(PropertyGroup):
ifc_definition_id: int
class PendingArrayRepair(PropertyGroup):
"""One array parent whose ``BBIM_Array.Data`` references at least one
child GUID that does not resolve in the current IFC file. The user can
select these parents from the Project panel banner to inspect them."""
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
ifc_definition_id: int
class BIMProjectProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
is_loading: BoolProperty(name="Is Loading", default=False)
@@ -390,7 +372,6 @@ class BIMProjectProperties(PropertyGroup):
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
)
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
pending_array_repair: CollectionProperty(name="Pending Array Repair", type=PendingArrayRepair)
style_limit: IntProperty(
name="Style Limit",
default=300,
@@ -557,7 +538,6 @@ class BIMProjectProperties(PropertyGroup):
angular_tolerance: float
void_limit: int
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
pending_array_repair: bpy.types.bpy_prop_collection_idprop[PendingArrayRepair]
style_limit: int
distance_limit: float
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
+31 -1
View File
@@ -28,8 +28,9 @@ from bpy.types import Menu, Panel, UIList
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore
from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
from bonsai.bim.module.project.data import LinksData, ProjectData
from bonsai.bim.ui import draw_multiline_text
if TYPE_CHECKING:
from bonsai.bim.module.project.prop import (
@@ -166,6 +167,20 @@ class BIM_PT_project(Panel):
if pprops.is_loading:
self.draw_advanced_loading_ui(context)
elif self.file or props.ifc_file:
if is_cache_locked_by_other_process():
box = self.layout.box()
box.alert = True
row = box.row(align=True)
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
draw_multiline_text(
box.column(align=True),
"This file is open in another Blender instance. Editing the same "
"IFC from two instances at once can lose your work or display "
"outdated geometry. Close the other Blender instances to continue safely.",
context=context,
)
if props.has_blend_warning:
box = self.layout.box()
box.alert = True
@@ -175,6 +190,21 @@ class BIM_PT_project(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
if pending := pprops.pending_opening_recut:
box = self.layout.box()
box.alert = True
box.label(text="Opening Cuts Skipped", icon="ERROR")
draw_multiline_text(
box.column(align=True),
f"{len(pending)} element(s) had too many openings to cut during load. "
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
context=context,
)
row = box.row(align=True)
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
if props.ifc_file:
self.draw_loaded_project_ui(context)
else:
@@ -113,8 +113,6 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
elif props.active_pset_type == "QTO":
pset = ifcopenshell.api.pset.add_qto(self.file, product=element, name=props.active_pset_name)
props.active_pset_id = pset.id()
else:
assert False
if self.properties:
properties = json.loads(self.properties)
@@ -228,8 +228,6 @@ def get_qto_name(self: "PsetProperties", context: bpy.types.Context) -> tool.Ble
if "bpy.data.objects" in pset_type:
if prop_type == "PsetProperties":
results = get_object_qto_name(self, context)
else:
assert False
elif prop_type == "TaskPsetProperties":
results = get_task_qto_names(self, context)
elif prop_type == "ResourcePsetProperties":
-1
View File
@@ -480,7 +480,6 @@ class BIM_PT_material_psets(Panel):
def draw(self, context):
assert self.layout
props = tool.Material.get_material_props()
ifc_definition_id = None
if material := props.active_material:
ifc_definition_id = material.ifc_definition_id

Some files were not shown because too many files have changed in this diff Show More