Compare commits

..

24 Commits

Author SHA1 Message Date
Ryan Schultz ac18195361 Merge v0.8.0 into light/newUI
Resolved conflict in light/operator.py by keeping the modular
re-export architecture from light/newUI and incorporating
RADIANCE_OT_select_camera that was missed during refactoring.
Added it to render.py, registered in __init__.py, and restored
the eyedropper button in ui.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:59:14 -05:00
Chirag Singh 394eb54f80 Light: Fix lint errors (unused imports, import sorting, formatting) 2026-04-03 01:40:32 +05:30
Chirag Singh f91f44cd4a Light/ Ran black formatting 2026-04-03 01:30:59 +05:30
Chirag Singh d835577750 Merge branch 'light/newUI' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-04-03 01:22:37 +05:30
Chirag Singh fffb444b20 Merge v0.8.0 into light/newUI 2026-04-03 01:11:16 +05:30
Chirag Singh a3f212c021 Light: UI revamp with sub-panels, module split, falsecolor via system Radiance 2026-04-03 01:07:48 +05:30
Chirag Singh c627d59bda Light: Optimized radiance rendering workflow 2026-03-21 18:25:49 +05:30
Ryan Schultz fd13cd6c13 Fix AttributeError by removing duplicate bim.enum_property_search operator
The light module (PR #5452) defined its own EnumPropertySearch class with
bl_idname = "bim.enum_property_search", conflicting with the main
BIM_OT_enum_property_search in operator.py. The light module's version
lacked should_click_ok and other properties, so whichever class registered
last caused an AttributeError when helper.py tried to set op.should_click_ok.

Remove the duplicate EnumPropertySearch and SetEnumProperty operators from
light/operator.py and __init__.py entirely, and replace the local
prop_with_search/get_enum_items helpers in light/ui.py with an import of
the canonical prop_with_search from bonsai.bim.helper.

Generated with the assistance of an AI coding tool.
2026-03-11 17:44:47 -05:00
Ryan Schultz afd6f422ee Fix AttributeError in prop_with_search caused by duplicate bl_idname
The light module's EnumPropertySearch operator was using the same
bl_idname ("bim.enum_property_search") as the main BIM_OT_enum_property_search
in operator.py, but only defined prop_name and search_term properties,
lacking should_click_ok and others. Whichever class registered last would
overwrite the other, causing an AttributeError when helper.py tried to
set op.should_click_ok on the stripped-down version.

Renamed the light module operator to "radiance.enum_property_search" and
updated the matching call in light/ui.py.

Generated with the assistance of an AI coding tool.
2026-03-11 17:39:00 -05:00
falken10vdl bb661ce998 Add bpy import to light list module
To avoid error:
File "/home/falken10vdl/.config/blender/5.0/extensions/.local/lib/python3.11/site-packages/bonsai/bim/module/light/list.py", line 33, in <module>
class MATERIAL_UL_radiance_materials(bpy.types.UIList):
^^^
NameError: name 'bpy' is not defined

Cheers!
2026-03-11 09:42:18 +01:00
Chirag Singh f40377b93d Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-03-08 23:01:34 +05:30
Chirag Singh 30210b0bf6 Merge branch 'light/newUI' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-02-10 13:41:12 +05:30
Chirag Singh f7d438f759 Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-02-10 13:37:05 +05:30
Ryan Schultz 8534abe0e0 Try this again. 2025-11-27 09:19:00 -06:00
Ryan Schultz 323d6db4d1 Revert "trying @falken10vdl's suggestion https://github.com/IfcOpenShell/IfcOpenShell/pull/5452#issuecomment-3552472786"
This reverts commit 99e20cae64.
2025-11-27 09:14:14 -06:00
Ryan Schultz 99e20cae64 trying @falken10vdl's suggestion https://github.com/IfcOpenShell/IfcOpenShell/pull/5452#issuecomment-3552472786 2025-11-27 08:22:34 -06:00
Chirag Singh 5e10752bd7 Light: Fixed all issues 2025-11-27 18:21:45 +05:30
Chirag Singh 39839cff66 Ran balck Formatting 2025-11-15 21:43:48 +05:30
Chirag Singh a570d81fd7 Light(Radiance): Implemented IES File Mapping | Render False Color Images | Updated UI 2025-11-15 21:40:13 +05:30
Chirag Singh f6e23c0462 Light: Ran Black Formatting 2025-11-07 22:44:59 +05:30
Chirag Singh dcd87260e7 Light: Removed Comments, Added Sky render options in UI 2025-11-07 22:44:03 +05:30
Chirag Singh 7d148456c0 Light: Updated Radiance Exported code based on latest Pyradiance library update 2025-11-07 22:08:17 +05:30
Chirag Singh 55568e122d Ran black formatting 2025-11-07 21:26:26 +05:30
Chirag Singh 776112bf1d split obj generation separate from other material rad / rtm generation 2025-11-07 21:25:46 +05:30
193 changed files with 4927 additions and 21179 deletions
@@ -1,95 +0,0 @@
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
# "PyGithub",
# "requests",
# ]
# ///
import os
from pathlib import Path
import requests
from github import Github
from github.GitReleaseAsset import GitReleaseAsset
EXTENSION_ID = "bonsai"
CURRENT_PYTHON_VERSION = "py313"
CURRENT_PLATFORMS = ["linux-x64", "macos-arm64", "windows-x64"]
def publish_asset(asset: GitReleaseAsset, token: str, repo_root: Path) -> None:
"""
Publish an asset to Blender Extensions.
Reference: https://extensions.blender.org/api/v1/swagger
"""
temp_path = repo_root / asset.name
response = requests.get(asset.browser_download_url)
response.raise_for_status()
temp_path.write_bytes(response.content)
url = f"https://extensions.blender.org/api/v1/extensions/{EXTENSION_ID}/versions/upload/"
headers = {"Authorization": f"Bearer {token}"}
files = {"version_file": temp_path.read_bytes()}
response = requests.post(url, headers=headers, files=files)
response.raise_for_status()
temp_path.unlink()
print(f"✓ Published {asset.name}")
def main() -> None:
token = os.getenv("BLENDER_EXTENSIONS_TOKEN")
if not token:
raise Exception("BLENDER_EXTENSIONS_TOKEN environment variable not set")
# Get the repository root
repo_root = Path(__file__).parent.parent.parent
# Read VERSION file
version_file = repo_root / "VERSION"
version = version_file.read_text().strip()
print(f"Current VERSION: {version}")
tag_name = f"bonsai-{version}"
# Get release from GitHub
gh = Github()
gh_repo = gh.get_repo("IfcOpenShell/IfcOpenShell")
release = gh_repo.get_release(tag_name)
assets = release.get_assets()
asset_platform_map: dict[str, tuple[GitReleaseAsset, str]] = {}
for asset in assets:
if CURRENT_PYTHON_VERSION not in asset.name:
continue
for platform in CURRENT_PLATFORMS:
if platform in asset.name:
asset_platform_map[asset.name] = (asset, platform)
break
if len(asset_platform_map) != len(CURRENT_PLATFORMS):
found_platforms = {platform for _, (_, platform) in asset_platform_map.items()}
missing_platforms = set(CURRENT_PLATFORMS) - found_platforms
raise Exception(
f"Expected {len(CURRENT_PLATFORMS)} assets but found {len(asset_platform_map)}. "
f"Missing: {', '.join(sorted(missing_platforms))}"
)
print("\nRelease assets:")
for asset_name in sorted(asset_platform_map.keys()):
print(f"- {asset_name}")
# https://extensions.blender.org/api/v1/swagger
print("\nPublishing assets to Blender Extensions:")
for asset_name, (asset, platform) in asset_platform_map.items():
publish_asset(asset, token, repo_root)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: mac-${{ matrix.arch }}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
+5 -11
View File
@@ -9,13 +9,6 @@ jobs:
container: rockylinux:9
steps:
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install Python
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
run: uv python install
- name: Install Dependencies
run: |
dnf update -y
@@ -24,6 +17,7 @@ jobs:
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
findutils xz byacc
python3 -m pip install typing_extensions
git config --global --add safe.directory '*'
- name: Install aws cli
@@ -51,10 +45,10 @@ jobs:
- name: Unpack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py unpack
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
@@ -62,7 +56,7 @@ jobs:
shell: bash
run: |
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
- name: Upload Build Logs
if: always()
@@ -77,7 +71,7 @@ jobs:
- name: Pack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py pack
python3 ../nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
+5 -11
View File
@@ -9,13 +9,6 @@ jobs:
container: arm64v8/rockylinux:9
steps:
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install Python
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
run: uv python install
- name: Install Dependencies
run: |
dnf update -y
@@ -24,6 +17,7 @@ jobs:
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
findutils xz byacc
python3 -m pip install typing_extensions
git config --global --add safe.directory '*'
- name: Install aws cli
@@ -51,10 +45,10 @@ jobs:
- name: Unpack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py unpack
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
@@ -62,7 +56,7 @@ jobs:
shell: bash
run: |
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
- name: Upload Build Logs
if: always()
@@ -77,7 +71,7 @@ jobs:
- name: Pack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py pack
python3 ../nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
uses: hendrikmuhs/ccache-action@v1.2.22
-
name: Build ifcopenshell
+3 -2
View File
@@ -30,7 +30,7 @@ jobs:
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty==0.0.34
uv tool install ty
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
@@ -95,7 +95,8 @@ jobs:
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
}
run_check poe ruff
run_check poe ruff-main
run_check poe ruff-old
exit $ERROR
continue-on-error: true
+2 -2
View File
@@ -51,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
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
pip install src/bcf --no-deps
pip install pytest-xdist==3.8.0
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
@@ -1,16 +0,0 @@
name: Publish Bonsai Releases
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
- run: uv run .github/scripts/publish-bonsai-releases.py
env:
BLENDER_EXTENSIONS_TOKEN: ${{ secrets.BLENDER_EXTENSIONS_TOKEN }}
+8 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/python
# /// script
# ///
###############################################################################
# #
# This file is part of IfcOpenShell. #
@@ -126,9 +124,16 @@ ssl._create_default_https_context = ssl._create_unverified_context
import time
from collections.abc import Generator, Sequence
from pathlib import Path
from typing import Literal, Union
from urllib.request import urlretrieve
try:
from typing import Literal, Union
except:
# python 3.6 compatibility for rocky 8
from typing import Union
from typing_extensions import Literal
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
-2
View File
@@ -1,5 +1,3 @@
# /// script
# ///
"""
Cache built dependencies for builds.
+4 -10
View File
@@ -1,11 +1,6 @@
#!/usr/bin/bash
set -ex
PYODIDE_VERSION=0.29.3
PYODIDE_BUILD_VERSION=0.33.0
PYODIDE_XBUILDENV_ROOT="${HOME}/.cache/.pyodide-xbuildenv-${PYODIDE_BUILD_VERSION}"
PYODIDE_XBUILDENV="${PYODIDE_XBUILDENV_ROOT}/${PYODIDE_VERSION}"
# Script is assuming that it will be possible to execute it multiple times
# therefore we're clearing venv each time and ignoring existing 'emsdk' folder.
@@ -16,15 +11,14 @@ source .venv/bin/activate
# Install pyodide cross build environment.
# Instructions: https://pyodide.org/en/stable/development/building-packages.html
uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}"
uv pip install pyodide-build
# `uv run` is required, so xbuildenv would skip using `pip`.
uv run pyodide xbuildenv install "${PYODIDE_VERSION}"
uv run pyodide xbuildenv install
uv run pyodide xbuildenv install-emscripten
EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk"
source "${EMSDK_ROOT}/emsdk_env.sh"
EMSDK_ROOT=$(pyodide config get emscripten_dir)
source ${EMSDK_ROOT}/emsdk_env.sh
which emcc
emcc --version
mkdir -p packages/ifcopenshell
VERSION=`cat IfcOpenShell/VERSION`
+7 -4
View File
@@ -3,9 +3,9 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.12",
"ruff==0.15.9",
"poethepoet",
"ty==0.0.32",
"ty==0.0.29",
"gersemi==0.26.1",
]
@@ -215,7 +215,10 @@ exclude = [
[tool.poe.tasks]
ruff = "ruff check"
ruff-main = "ruff check --extend-exclude nix/build-all.py"
# It's actually Python 3.6, but ruff only supports 3.7+, but it should do.
ruff-old = "ruff check nix/build-all.py --target-version py37"
ruff.sequence = ["ruff-main", "ruff-old"]
black = "black ."
@@ -235,7 +238,7 @@ ty-venv-ios.sequence = [
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
]
format.sequence = ["black", "ruff"]
format.sequence = ["black", "ruff-main", "ruff-old"]
cmake-format = "gersemi . --in-place"
+3 -11
View File
@@ -17,8 +17,8 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
SHELL := sh
PYTHON:=python3
PIP:=pip3
PYTHON:=python3.11
PIP:=pip3.11
PATCH:=patch
SED:=sed -i
VENV_ACTIVATE:=bin/activate
@@ -192,11 +192,7 @@ endif
# Provides networkx graph analysis for project dependency calculations
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
# Required by IFCDiff
# Pinned <9.1: deepdiff 9.1.0 adds cachebox<6,>=5.2 which only ships macOS x86_64
# wheels for macosx_10_12+ and is incompatible with our macos py311 --platform
# macosx_10_10_x86_64 target. Revisit once the macos py311 platform tag is bumped
# to 10_13 (matching py312/py313).
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download "deepdiff<9.1" --dest=./wheels
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
# Required by IFCCSV and ifcopenshell.util.selector
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
# Required by IFC4D
@@ -360,10 +356,6 @@ else
pytest test/tool/test_$(MODULE).py --maxfail=1
endif
.PHONY: test-modal
test-modal:
blender --enable-event-simulate --python test/modal/test_modal.py --window-maximized
# Reregistering test is not added to the standard test suite because during unregister
# Blender removes all Bonsai dependencies breaking dev-environment symlinks.
.PHONY: test-reregister
+4 -22
View File
@@ -15,8 +15,6 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import importlib
import os
@@ -27,19 +25,7 @@ import bpy
import bpy.utils.previews
from bpy_extras.io_utils import ExportHelper, ImportHelper
from . import handler, operator, parametric_lifecycle, prop, ui
def _parametric_gizmo_preference_classes() -> list[type]:
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
``classes`` list below. ``import bonsai.tool`` is kept local to surface
the load-order constraint: it relies on ``from . import handler, …``
above having primed the
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
import bonsai.tool as tool
return tool.Parametric.iter_gizmo_preference_classes(ui)
from . import handler, operator, prop, ui
try:
from bonsai.translations import translations_dict
@@ -171,10 +157,9 @@ classes = [
ui.BIM_UL_tab_visibilities,
ui.BIM_UL_panel_visibilities,
ui.DocPreferences,
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
*_parametric_gizmo_preference_classes(),
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
ui.GizmoPreferencesStair, # Register before GizmoPreferences
ui.GizmoPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel
@@ -283,8 +268,6 @@ def register():
bpy.app.handlers.depsgraph_update_post.append(on_register)
bpy.app.handlers.undo_post.append(handler.undo_post)
bpy.app.handlers.redo_post.append(handler.redo_post)
# Must follow the two appends above so regenerators see restored IFC state.
parametric_lifecycle.install_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.load_post.append(handler.loadIfcStore)
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
@@ -342,7 +325,6 @@ def unregister():
unregister_classes(classes)
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
del bpy.types.Scene.BIMProperties
-96
View File
@@ -1,96 +0,0 @@
Copyright (c) 2011-2012, Nikita Volchenkov (<nikitavolchenkov@gmail.com>),
with Reserved Font Name OpenGost Type B.
Copyright (c) 2012, Valek Filippov (<frob@gnome.org>).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-119
View File
@@ -1,119 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared structural-change cache token for POST_VIEW decorators.
Decorators include the token in their cache key and rebuild on bump."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Generic, TypeVar
import bpy
T = TypeVar("T")
_DECORATOR_CACHE_TOKEN = 0
def get_decorator_cache_token() -> int:
return _DECORATOR_CACHE_TOKEN
def reset_for_test() -> None:
"""Test-only: reset the cache token to 0 so bump-count assertions are stable."""
global _DECORATOR_CACHE_TOKEN
_DECORATOR_CACHE_TOKEN = 0
@bpy.app.handlers.persistent
def _bump_decorator_cache_token(*args: Any) -> None:
"""depsgraph_update_post fires every animation frame and every driver
evaluation, even when no IFC-relevant ID block changed. Unconditional
bumping defeats the cache: an animated scene rebuilds every decorator
every viewport tick. Gate the depsgraph path on Object geometry or
transform updates; undo / redo / load have no depsgraph and always
invalidate.
Coverage assumption: ``TokenCache`` consumers key on Object identity
(depsgraph updates whose ``id`` is a ``bpy.types.Object``). Mesh /
Material / NodeTree updates that don't surface as an Object change
do NOT invalidate the token — a decorator that caches material- or
mesh-data-derived state must gate on a separate signal."""
global _DECORATOR_CACHE_TOKEN
if len(args) >= 2:
depsgraph = args[1]
if depsgraph is not None and hasattr(depsgraph, "updates"):
if not any(
(getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False))
and hasattr(u, "id")
and isinstance(u.id, bpy.types.Object)
for u in depsgraph.updates
):
return
_DECORATOR_CACHE_TOKEN += 1
def _hooks() -> tuple[Any, ...]:
return (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
def install_decorator_cache_handlers() -> None:
"""Append the bump handler to each hook; idempotent."""
for hook in _hooks():
if _bump_decorator_cache_token not in hook:
hook.append(_bump_decorator_cache_token)
def uninstall_decorator_cache_handlers() -> None:
for hook in _hooks():
try:
hook.remove(_bump_decorator_cache_token)
except ValueError:
pass
class TokenCache(Generic[T]):
"""Memoise a single value keyed on ``(caller_key, get_decorator_cache_token())``.
The token component invalidates the cache on depsgraph / undo / redo / load,
so cached ``bpy.types.Object`` references can't outlive the underlying ID
blocks. Holds exactly one entry — last key wins."""
__slots__ = ("_key", "_value")
def __init__(self) -> None:
self._key: tuple[Any, int] | None = None
self._value: T | None = None
def get_or_compute(self, key: Any, compute: Callable[[], T]) -> T:
token_key = (key, _DECORATOR_CACHE_TOKEN)
if token_key == self._key:
return self._value # type: ignore[return-value]
value = compute()
self._key = token_key
self._value = value
return value
+31 -104
View File
@@ -15,12 +15,11 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import os
import weakref
from collections.abc import Callable
from math import cos
from typing import Union
import bpy
@@ -32,13 +31,8 @@ from bpy.app.handlers import persistent
from mathutils import Vector
import bonsai.bim
import bonsai.core.model as core_model
import bonsai.tool as tool
from bonsai.bim.decorator_cache import (
install_decorator_cache_handlers,
uninstall_decorator_cache_handlers,
)
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
from bonsai.bim.module.model.data import AuthoringData
@@ -46,9 +40,7 @@ from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
)
from bonsai.bim.module.model.preview_base import discard_pending_previews
from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
@@ -141,39 +133,14 @@ def update_bim_tool_props():
if is_annotation_tool and (object_type := tool.Drawing.get_annotation_type_object_type(element_type)):
aprops.object_type = object_type
try:
aprops.relating_type_id = str(element_type.id())
except TypeError:
# EnumProperty items are rebuilt asynchronously when ifc_class changes;
# this assignment can race a stale item list. Skipping is harmless —
# the UI will resync on the next active_object_callback.
pass
aprops.relating_type_id = str(element_type.id())
return
if is_bim_tool:
try:
props.ifc_class = element_type.is_a()
except TypeError:
# ifc_class only lists element/space types present in the model, so an
# unsupported type (e.g. a raw IfcTypeProduct) or a stale item list mid-
# rebuild raises `enum "<class>" not found`. Skip rather than crash the
# handler — it re-fires on the next selection and the panel resyncs.
pass
props.ifc_class = element_type.is_a()
# Only assign when the target enum is the one that lists this type — otherwise
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
# different class than the workspace tool was built for (e.g. selecting a wall
# while the door tool is active).
tool_class_match = TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a()
bim_tool_class_match = is_bim_tool and props.ifc_class == element_type.is_a()
if bim_tool_class_match or tool_class_match:
try:
props.relating_type_id = str(element_type.id())
except TypeError:
# Defensive: the enum item list can lag behind ifc_class assignment
# above. Skipping leaves the panel briefly out of sync rather than
# crashing the handler (which Blender re-fires on every selection).
pass
if is_bim_tool or TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a():
props.relating_type_id = str(element_type.id())
if is_annotation_tool:
return
@@ -198,9 +165,7 @@ def update_bim_tool_props():
if AuthoringData.data["active_material_usage"] == "LAYER2":
x_angle = get_x_angle(extrusion)
axis = tool.Model.get_wall_axis(obj)["reference"]
props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
extrusion.Depth * si_conversion, x_angle
)
props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle))
props.length = (axis[1] - axis[0]).length
props.x_angle = x_angle
@@ -391,10 +356,8 @@ def subscribe_to_viewport_shading_changes():
)
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
and previews discarded so saved preview state never resurfaces on reopen."""
@persistent
def load_post(scene):
global global_subscription_owner
active_object_key = bpy.types.LayerObjects, "active"
bpy.msgbus.subscribe_rna(
@@ -405,24 +368,6 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
ifcopenshell.api.owner.settings.get_application = get_application
AuthoringData.type_thumbnails = {}
tool.Parametric.heal_stale_edit_flags()
discard_pending_previews(scene)
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
props.has_blend_warning = True
# Probe the H5 cooked-geometry cache so the multi-instance warning surfaces
# right after .blend load. Without this, the lock is only detected when a
# mutation triggers ``clear_cache`` — by which time the user has already
# made changes that may now conflict with the other Blender instance.
if tool.Ifc.get():
get_cache_or_detect_lock()
def _apply_user_preferences() -> None:
"""User-preference-driven UI setup: toolbar, BIM workspace, viewport shading
subscription, scene-panel hijack, tab layout, snap defaults."""
preferences = tool.Blender.get_addon_preferences()
if not preferences.should_setup_toolbar:
tool.Blender.unregister_toolbar()
@@ -446,21 +391,11 @@ def _apply_user_preferences() -> None:
tool.Blender.override_scene_panel(panel)
tool.Blender.setup_tabs()
if preferences.should_use_snap and (scene := bpy.context.scene):
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
scene.tool_settings.use_snap = True
# Match default Bonsai snaps
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
props.has_blend_warning = True
tool.Blender.sync_old_preferences()
def _install_viewport_overlays() -> None:
"""Sync every Bonsai viewport decorator to its enabled state.
Wrapped in uninstall/install of the decorator-cache bump handlers so a
decorator's own install path doesn't double-bind to depsgraph_update_post
via ``TokenCache`` instances created during their own ``install()``."""
# Bonsai overlays
georeference_props = tool.Georeference.get_georeference_props()
aggregate_props = tool.Aggregate.get_aggregate_props()
nest_props = tool.Nest.get_nest_props()
@@ -470,31 +405,23 @@ def _install_viewport_overlays() -> None:
NestDecorator.uninstall()
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
uninstall_decorator_cache_handlers()
try:
if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context)
if aggregate_props.aggregate_decorator:
AggregateDecorator.install(bpy.context)
if nest_props.nest_decorator:
NestDecorator.install(bpy.context)
if model_props.show_wall_axis:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
# wall_fillet.is_active, so installation has no cost when no preview
# is open. No corresponding addon-preference toggle.
WallFilletPreviewDecorator.install(bpy.context)
finally:
install_decorator_cache_handlers()
if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context)
if aggregate_props.aggregate_decorator:
AggregateDecorator.install(bpy.context)
if nest_props.nest_decorator:
NestDecorator.install(bpy.context)
if model_props.show_wall_axis:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
if preferences.should_use_snap and (scene := bpy.context.scene):
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
scene.tool_settings.use_snap = True
# Match default Bonsai snaps
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
@persistent
def load_post(scene):
_apply_save_file_invariants(scene)
_apply_user_preferences()
_install_viewport_overlays()
tool.Blender.sync_old_preferences()
+1 -40
View File
@@ -64,44 +64,6 @@ class TransactionStep(TypedDict):
operations: list[Operation]
# Set when ``IfcStore.get_cache`` observes an external lock on the HDF5 cache —
# signal that another Blender process has the same IFC file open. Project panel
# polls ``is_cache_locked_by_other_process`` to warn the user. The dismissed
# flag is sticky per-session so the warning doesn't re-nag once the user has
# acknowledged it.
_cache_locked_by_other_process: bool = False
_multi_instance_warning_dismissed: bool = False
def is_cache_locked_by_other_process() -> bool:
return _cache_locked_by_other_process and not _multi_instance_warning_dismissed
def dismiss_multi_instance_warning() -> None:
global _multi_instance_warning_dismissed
_multi_instance_warning_dismissed = True
def get_cache_or_detect_lock() -> ifcopenshell.geom.serializers.hdf5 | None:
"""Like ``IfcStore.get_cache`` but tracks the multi-instance lock flag — sets
it on ``PermissionError``, clears it (along with the dismiss flag) when a
subsequent call succeeds. Returns ``None`` on lock; other exceptions
propagate. Callers that don't need the warning side effect can use
``IfcStore.get_cache`` directly."""
global _cache_locked_by_other_process, _multi_instance_warning_dismissed
try:
cache = IfcStore.get_cache()
except PermissionError:
_cache_locked_by_other_process = True
return None
if _cache_locked_by_other_process:
# Lock released — clear both flags so a future re-locking re-surfaces
# the warning rather than staying suppressed by the previous dismiss.
_cache_locked_by_other_process = False
_multi_instance_warning_dismissed = False
return cache
class IfcStore:
path: str = ""
"""Should be set only using ``tool.Ifc.set_path``."""
@@ -234,7 +196,7 @@ class IfcStore:
shutil.copy2(IfcStore.cache_path, new_cache_path)
except PermissionError:
pass # Well we tried. No cache for you!
get_cache_or_detect_lock()
IfcStore.get_cache()
@staticmethod
def load_file(path: str) -> None:
@@ -552,7 +514,6 @@ class IfcStore:
BrickStore.end_transaction()
IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data()
tool.Parametric.refresh_post_commit()
if method == "MODAL":
cls.modal_in_progress = False
@@ -139,7 +139,6 @@ class BIMAggregateProperties(PropertyGroup):
previous_editing_aggregate: PointerProperty(name="Editing Aggregate", type=bpy.types.Object)
editing_objects: CollectionProperty(type=Objects)
not_editing_objects: CollectionProperty(type=Objects)
previously_selected_objects: CollectionProperty(type=Objects)
aggregate_decorator: BoolProperty(
name="Display Aggregate",
default=False,
@@ -156,6 +155,5 @@ class BIMAggregateProperties(PropertyGroup):
previous_editing_aggregate: Union[bpy.types.Object, None]
editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
not_editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
previously_selected_objects: bpy.types.bpy_prop_collection_idprop[Objects]
aggregate_decorator: bool
previous_state: bool
+1 -3
View File
@@ -48,14 +48,12 @@ def draw_ui(context: bpy.types.Context, layout: bpy.types.UILayout, attributes)
row = layout.row()
op = row.operator("bim.enable_editing_attributes", icon="GREASEPENCIL", text="Edit")
element = tool.Ifc.get_entity(obj)
key_prefix = "type." if (element and element.is_a("IfcTypeObject")) else ""
for attribute in attributes:
row = layout.row(align=True)
row.label(text=attribute["name"])
value = bonsai.bim.helper.get_display_value(attribute["value"])
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
op.key = key_prefix + attribute["name"]
op.key = attribute["name"]
# TODO: reimplement, see #1222
# if "IfcSite/" in context.active_object.name or "IfcBuilding/" in context.active_object.name:
@@ -15,8 +15,6 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import bpy
@@ -138,32 +136,14 @@ classes = (
gizmos.GizmoArrow2D,
gizmos.GizmoCone,
gizmos.GizmoDimension,
gizmos.GizmoLockOpen,
gizmos.GizmoLockClosed,
gizmos.GizmoLock,
gizmos.GizmoArc,
gizmos.GizmoFillet,
gizmos.GizmoWallCornerIcon,
gizmos.GizmoWallTeeIcon,
gizmos.GizmoPen,
gizmos.GizmoValidate,
gizmos.GizmoCancel,
gizmos.GizmoPlus,
gizmos.GizmoMinus,
gizmos.GizmoTrash,
gizmos.GizmoArrayParent,
gizmos.GizmoArrayAll,
gizmos.GizmoArrayLayerIndicator,
gizmos.GizmoMerge,
gizmos.GizmoSplit,
gizmos.GizmoUnjoin,
gizmos.GizmoExtend,
gizmos.GizmoExtendVertical,
gizmos.GizmoOffsetExterior,
gizmos.GizmoOffsetCenter,
gizmos.GizmoOffsetInterior,
gizmos.GizmoAddOpening,
gizmos.GizmoCycle,
gizmos.GizmoMenu,
# Drawing-specific gizmos
gizmos.UglyDotGizmo,
gizmos.ExtrusionGuidesGizmo,
File diff suppressed because it is too large Load Diff
@@ -313,7 +313,7 @@ def format_distance(
if not feet and not add_inches:
tx_dist += str(feet) + "'"
if not feet and add_inches and unit_length != "INCHES":
if not feet and add_inches:
if value < 0:
tx_dist += "-0' - "
else:
@@ -44,12 +44,8 @@ class ViewportData:
@classmethod
def load(cls):
# Populate data BEFORE flipping is_loaded so a raising ``mode()``
# call doesn't leave the class half-loaded (flag set, dict empty).
# Subsequent items-callback invocations skip load() on a True flag
# and would hit ``cls.data["mode"]`` → KeyError.
cls.data = {"mode": cls.mode()}
cls.is_loaded = True
cls.data = {"mode": cls.mode()}
@classmethod
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
@@ -60,7 +60,6 @@ import bonsai.core.root
import bonsai.core.spatial
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import ProfileDecorator
if TYPE_CHECKING:
@@ -1184,7 +1183,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator: bpy.types.Operator, context: bpy.types.Context, linked: bool = False
) -> set["rna_enums.OperatorReturnItems"]:
# Deep magick from the dawn of time
if tool.Ifc.get() and tool.Model.has_selected_ifc_objects(include_active=False):
if tool.Ifc.get():
IfcStore.execute_ifc_operator(operator, context)
return {"FINISHED"}
@@ -1288,11 +1287,6 @@ class OverrideDuplicateMove(bpy.types.Operator):
if part_obj:
all_objects_to_select.add(part_obj)
# Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
all_objects_to_select.update(
obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj)
)
# Deselect everything first
bpy.ops.object.select_all(action="DESELECT")
@@ -2229,8 +2223,6 @@ class OverrideEscape(bpy.types.Operator):
bpy.ops.bim.hide_all_openings()
elif tool.Aggregate.get_aggregate_props().in_aggregate_mode:
bpy.ops.bim.disable_aggregate_mode()
elif preview_base.try_cancel_active_preview(context):
pass
elif active_object := context.active_object:
if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object):
pass
@@ -2272,8 +2264,6 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
gprops = tool.Geometry.get_geometry_props()
if gprops.representation_obj:
tool.Geometry.disable_item_mode()
if active_obj := bpy.context.active_object:
active_obj.select_set(False)
else:
bonsai.core.aggregate.exit_aggregate_mode(tool.Aggregate)
return {"FINISHED"}
@@ -2360,7 +2350,6 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
and usage in ("LAYER1", "LAYER2")
):
self.report({"INFO"}, f"Parametric {usage} elements cannot be edited directly")
obj.select_set(False)
elif item.is_a("IfcSweptAreaSolid"):
tool.Geometry.sync_item_positions()
res = tool.Model.import_profile((profile := item.SweptArea), obj=obj)
@@ -2369,7 +2358,6 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
{"INFO"},
f"Couldn't import profile, editing it directly is not yet supported. Failing profile: {profile}.",
)
obj.select_set(False)
return
tool.Ifc.link(item, obj.data)
self.enable_edit_mode(context)
+2 -25
View File
@@ -19,7 +19,6 @@
import bpy
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
@@ -484,32 +483,10 @@ class BIM_PT_placement(Panel):
row.label(text="No Object Placement Found")
return
is_imperial = False
if tool.Ifc.get():
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
if length_unit and length_unit.Name != "METRE":
is_imperial = True
row = self.layout.row()
row.label(text="Location:")
if is_imperial:
loc = context.active_object.location
for i, (axis, comp) in enumerate(zip("XYZ", (loc.x, loc.y, loc.z))):
split = self.layout.split(factor=0.6)
split.prop(context.active_object, "location", index=i, text=axis)
sub = split.row()
sub.enabled = False
sub.alignment = "LEFT"
sub.label(text=tool.Unit.format_distance(comp))
else:
for i, axis in enumerate("XYZ"):
self.layout.prop(context.active_object, "location", index=i, text=axis)
row.prop(context.active_object, "location", text="Location")
row = self.layout.row()
row.label(text="Rotation:")
for i, axis in enumerate("XYZ"):
self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis)
row.prop(context.active_object, "rotation_euler", text="Rotation")
if props.blender_offset_type != "NONE":
row = self.layout.row(align=True)
+29 -17
View File
@@ -23,7 +23,7 @@ from pathlib import Path
import bpy
import pyradiance
from . import list, operator, prop, ui
from . import export, ies, list, material, prepare, prop, render, solar, ui
def get_pyradiance_path():
@@ -31,31 +31,43 @@ def get_pyradiance_path():
classes = (
operator.ExportOBJ,
operator.ImportLatLong,
operator.ImportTrueNorth,
operator.MoveSunPathTo3DCursor,
operator.RadianceRender,
operator.ViewFromSun,
operator.LightPickCoordinates,
operator.LightSetTimeToNow,
operator.RefreshIFCMaterials,
operator.UnmapMaterial,
operator.RADIANCE_OT_select_camera,
operator.RADIANCE_OT_export_material_mappings,
operator.RADIANCE_OT_import_material_mappings,
operator.RADIANCE_OT_open_spectraldb,
export.ExportOBJ,
solar.ImportLatLong,
solar.ImportTrueNorth,
solar.MoveSunPathTo3DCursor,
render.RadianceRender,
render.FalseColorRadiance,
render.RADIANCE_OT_select_camera,
solar.ViewFromSun,
solar.LightPickCoordinates,
solar.LightSetTimeToNow,
material.RefreshIFCMaterials,
material.UnmapMaterial,
material.RADIANCE_OT_export_material_mappings,
material.RADIANCE_OT_import_material_mappings,
material.RADIANCE_OT_open_spectraldb,
prepare.PrepareRadianceScene,
ies.AddIESLight,
ies.RemoveIESLight,
export.CleanupRadianceFiles,
prop.RadianceMaterial,
prop.IESLight,
prop.BIMSolarProperties,
prop.RadianceExporterProperties,
ui.BIM_PT_radiance_exporter,
ui.BIM_PT_radiance_scene_setup,
ui.BIM_PT_radiance_materials,
ui.BIM_PT_radiance_lighting,
ui.BIM_PT_radiance_render_settings,
ui.BIM_PT_radiance_pipeline,
ui.BIM_PT_solar,
list.MATERIAL_UL_radiance_materials,
list.MATERIAL_UL_ies_lights,
)
def register():
bpy.types.Scene.BIMRadianceExporeterProperies = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMRadianceExporterProperties = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMSolarProperties = bpy.props.PointerProperty(type=prop.BIMSolarProperties)
if pyradiance:
@@ -68,5 +80,5 @@ def register():
def unregister():
del bpy.types.Scene.BIMRadianceExporeterProperies
del bpy.types.Scene.BIMRadianceExporterProperties
del bpy.types.Scene.BIMSolarProperties
@@ -0,0 +1,488 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import multiprocessing
import os
from pathlib import Path
import bpy
import ifcopenshell
import ifcopenshell.geom
import bonsai.tool as tool
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
class ExportOBJ(bpy.types.Operator):
"""Exports the IFC File to OBJ"""
bl_idname = "export_scene.radiance"
bl_label = "Export"
bl_description = "Export the IFC to OBJ"
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded in Bonsai.")
return False
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def _get_geom_settings(self):
"""Create standard geometry and serializer settings for OBJ export."""
settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
settings.set("apply-default-materials", True)
serializer_settings.set("use-element-guids", True)
settings.set("use-world-coords", True)
return settings, serializer_settings
def _get_exportable_elements(self, ifc_file, filter_visibility=True):
"""Get the list of elements to export from an IFC file."""
if ifc_file.schema in ("IFC2X3", "IFC4"):
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
else:
elements = ifc_file.by_type("IfcElement")
elements += ifc_file.by_type("IfcSite")
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
if not filter_visibility:
return elements
# Filter by visibility in Blender.
# We use hide_get() (user-toggled eye icon) instead of visible_get()
# because visible_get() also considers collection/view-layer visibility
# which incorrectly excludes objects in linked aggregate sub-collections.
visible_elements = []
for element in elements:
blender_obj = tool.Ifc.get_object(element)
if blender_obj is None:
# No Blender object (linked aggregate copies, or not yet represented)
# Include by default — the geometry exists in the IFC file
visible_elements.append(element)
continue
if not blender_obj.hide_get():
visible_elements.append(element)
else:
print(f"Skipping hidden element: {element.GlobalId if hasattr(element, 'GlobalId') else element.id()}")
return visible_elements
def _export_ifc_to_obj(self, ifc_file, obj_path, mtl_path, settings, serializer_settings, elements):
"""Export elements from an IFC file to OBJ format. Returns collected material names."""
materials_collected = []
serialiser = ifcopenshell.geom.serializers.obj(obj_path, mtl_path, settings, serializer_settings)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
print(f"Exporting {len(elements)} elements to {obj_path}")
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
if iterator.initialize():
while True:
shape = iterator.get()
for material in shape.geometry.materials:
materials_collected.append(material.name)
serialiser.write(shape)
if not iterator.next():
break
serialiser.finalize()
return materials_collected
def _sync_moved_object_placements(self):
"""Sync Blender object positions to IFC ObjectPlacements for all moved objects.
This is critical for linked aggregate copies: their IFC ObjectPlacements
are initially copies of the original's placement. After the user moves them
in Blender, the IFC placements are stale until explicitly synced. Without
this, the iterator with use-world-coords=True exports all copies at the
original position.
"""
import bonsai.core.geometry as core_geometry
synced = 0
for obj in bpy.data.objects:
element = tool.Ifc.get_entity(obj)
if element is None:
continue
if not element.is_a("IfcProduct"):
continue
try:
if tool.Ifc.is_moved(obj):
core_geometry.edit_object_placement(
ifc=tool.Ifc,
geometry=tool.Geometry,
surveyor=tool.Surveyor,
obj=obj,
apply_scale=False,
)
synced += 1
except Exception as e:
print(f"Could not sync placement for {obj.name}: {e}")
if synced:
print(f"Synced {synced} moved object placement(s) to IFC before export")
def _export_collection_instances_obj(self, context, output_dir):
"""Export Blender collection instances as per-parent OBJ files.
Collection instances (empties with instance_type='COLLECTION') are purely
Blender constructs they don't exist in the IFC file. The ifcopenshell
iterator ignores them entirely, so we must export their geometry via
Blender's depsgraph which evaluates all instances with correct world transforms.
Each collection instance parent gets its own OBJ file because obj2mesh
cannot handle all instances in a single file ("too many patch triangles").
"""
depsgraph = context.evaluated_depsgraph_get()
# Find which objects are collection instance parents (visible, not hidden)
# Skip collections that belong to linked IFC models — those are already
# exported by _export_linked_models() via the IFC serializer.
instance_parents = set()
for obj in bpy.data.objects:
if obj.instance_type == "COLLECTION" and obj.instance_collection is not None:
if not obj.visible_get():
continue
# Check if this collection contains linked IFC objects
coll = obj.instance_collection
is_linked_ifc = any("guids" in child for child in coll.all_objects if child.type == "MESH")
if is_linked_ifc:
print(f"Skipping collection instance '{obj.name}' (linked IFC model, exported via IFC serializer)")
continue
instance_parents.add(obj.name)
if not instance_parents:
return
print(f"Found {len(instance_parents)} collection instance(s) to export")
# Export one OBJ per collection instance parent
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
total_meshes = 0
for parent_name in sorted(instance_parents):
obj_path = os.path.join(output_dir, f"instance_{parent_name}.obj")
vert_offset = 0
mesh_count = 0
with open(obj_path, "w") as f:
f.write(f"# Collection instance geometry for {parent_name}\n")
f.write("usemtl white\n\n")
for dep_inst in depsgraph.object_instances:
if not dep_inst.is_instance:
continue
if dep_inst.parent is None:
continue
if dep_inst.parent.original.name != parent_name:
continue
eval_obj = dep_inst.object
if eval_obj.type != "MESH":
continue
try:
mesh = eval_obj.to_mesh()
except RuntimeError:
continue
if mesh is None:
continue
matrix = dep_inst.matrix_world
f.write(f"g obj_{mesh_count}\n")
for v in mesh.vertices:
co = matrix @ v.co
f.write(f"v {co.x} {co.y} {co.z}\n")
mesh.calc_loop_triangles()
for tri in mesh.loop_triangles:
i0 = vert_offset + tri.vertices[0] + 1
i1 = vert_offset + tri.vertices[1] + 1
i2 = vert_offset + tri.vertices[2] + 1
f.write(f"f {i0} {i1} {i2}\n")
vert_offset += len(mesh.vertices)
mesh_count += 1
eval_obj.to_mesh_clear()
if mesh_count == 0:
try:
os.remove(obj_path)
except OSError:
pass
continue
# Identity matrix — geometry is already at world coordinates
linked_model_exports.append((obj_path, "", identity))
total_meshes += mesh_count
print(f" {parent_name}: {mesh_count} meshes, {vert_offset} vertices")
if total_meshes > 0:
print(f"Exported {total_meshes} instanced meshes across {len(linked_model_exports)} file(s)")
def _export_non_ifc_meshes(self, context, output_dir):
"""Export visible Blender mesh objects that have no IFC entity.
These are plain Blender geometry (e.g. manually added planes, cubes)
that don't exist in any IFC file. They are skipped by the IFC iterator
and by the collection instance exporter, so we handle them separately.
"""
non_ifc_meshes = []
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
if not obj.visible_get():
continue
# Skip objects that have an IFC entity (handled by main/linked IFC export)
if tool.Ifc.get_entity(obj) is not None:
continue
# Skip objects inside instanced collections (handled by collection instance export)
if any(col.library for col in obj.users_collection):
continue
# Skip linked IFC element objects (have "guids" custom prop)
if "guids" in obj:
continue
non_ifc_meshes.append(obj)
if not non_ifc_meshes:
return
obj_path = os.path.join(output_dir, "blender_meshes.obj")
vert_offset = 0
mesh_count = 0
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
depsgraph = context.evaluated_depsgraph_get()
with open(obj_path, "w") as f:
f.write("# Non-IFC Blender mesh geometry\n")
f.write("usemtl white\n\n")
for obj in non_ifc_meshes:
eval_obj = obj.evaluated_get(depsgraph)
try:
mesh = eval_obj.to_mesh()
except RuntimeError:
continue
if mesh is None:
continue
matrix = obj.matrix_world
f.write(f"g {obj.name}\n")
for v in mesh.vertices:
co = matrix @ v.co
f.write(f"v {co.x} {co.y} {co.z}\n")
mesh.calc_loop_triangles()
for tri in mesh.loop_triangles:
i0 = vert_offset + tri.vertices[0] + 1
i1 = vert_offset + tri.vertices[1] + 1
i2 = vert_offset + tri.vertices[2] + 1
f.write(f"f {i0} {i1} {i2}\n")
vert_offset += len(mesh.vertices)
mesh_count += 1
eval_obj.to_mesh_clear()
if mesh_count == 0:
try:
os.remove(obj_path)
except OSError:
pass
return
linked_model_exports.append((obj_path, "", identity))
print(f"Exported {mesh_count} non-IFC Blender mesh(es) ({vert_offset} vertices)")
def execute(self, context):
ifc_materials.clear()
linked_model_exports.clear()
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
props.is_exporting = True
# Sync all moved Blender object positions to IFC before export.
self._sync_moved_object_placements()
settings, serializer_settings = self._get_geom_settings()
ifc_file = tool.Ifc.get()
# --- Export main model ---
obj_file_path = os.path.join(output_dir, "model.obj")
mtl_file_path = os.path.join(output_dir, "model.mtl")
visible_elements = self._get_exportable_elements(ifc_file, filter_visibility=True)
mats = self._export_ifc_to_obj(
ifc_file, obj_file_path, mtl_file_path, settings, serializer_settings, visible_elements
)
ifc_materials.extend(mats)
self.report({"INFO"}, f"Exported main model OBJ to: {obj_file_path}")
# --- Export linked models (external IFC files) ---
self._export_linked_models(ifc_file, output_dir, settings, serializer_settings)
# --- Export collection instances (Blender-level linked copies) ---
self._export_collection_instances_obj(context, output_dir)
# --- Export non-IFC Blender meshes (plain geometry with no IFC entity) ---
self._export_non_ifc_meshes(context, output_dir)
props.is_exporting = False
total_linked = len(linked_model_exports)
if total_linked:
self.report({"INFO"}, f"Also exported {total_linked} linked/instanced model(s)")
return {"FINISHED"}
def _export_linked_models(self, main_ifc_file, output_dir, settings, serializer_settings):
"""Detect and export all linked IFC models."""
try:
project_props = tool.Project.get_project_props()
except Exception:
print("Could not access project properties for linked models")
return
for idx, link in enumerate(project_props.links):
if not link.is_loaded:
print(f"Skipping linked model '{link.name}' (not loaded)")
continue
# Check if the link's empty handle is hidden in Blender
try:
link_empty = tool.Project.get_link_empty_handle(link)
if link_empty is not None and not link_empty.visible_get():
print(f"Skipping linked model '{link.name}' (hidden in viewport)")
continue
except Exception:
pass # If we can't check visibility, export anyway
try:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
except Exception as e:
print(f"Could not resolve path for linked model '{link.name}': {e}")
continue
if not filepath.exists():
print(f"Linked IFC file not found: {filepath}")
continue
print(f"Exporting linked model {idx}: {filepath.name}")
try:
linked_ifc = ifcopenshell.open(str(filepath))
except Exception as e:
print(f" Failed to open linked IFC: {e}")
continue
link_obj_path = os.path.join(output_dir, f"linked_{idx}.obj")
link_mtl_path = os.path.join(output_dir, f"linked_{idx}.mtl")
# For linked models we don't filter by Blender visibility
# (their objects are collection instances, not individually tracked)
elements = self._get_exportable_elements(linked_ifc, filter_visibility=False)
if not elements:
print(f" No exportable elements found in linked model")
continue
mats = self._export_ifc_to_obj(
linked_ifc, link_obj_path, link_mtl_path, settings, serializer_settings, elements
)
ifc_materials.extend(mats)
# Get the link transformation matrix
try:
link_matrix = tool.Project.calculate_link_matrix(link)
matrix_list = [list(row) for row in link_matrix]
except Exception as e:
print(f" Could not calculate link matrix: {e}, using identity")
matrix_list = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
linked_model_exports.append((link_obj_path, link_mtl_path, matrix_list))
print(f" Exported {len(elements)} elements from linked model '{link.name}'")
class CleanupRadianceFiles(bpy.types.Operator):
"""Delete all generated Radiance files from the output directory"""
bl_idname = "radiance.cleanup_files"
bl_label = "Cleanup Radiance Files"
bl_description = "Remove all generated files (OBJ, MTL, RTM, RAD, HDR, TIFF, DAT) from the output directory"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def execute(self, context):
import bonsai.bim.module.light.shared as shared
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
if not os.path.isdir(output_dir):
self.report({"WARNING"}, f"Output directory does not exist: {output_dir}")
return {"CANCELLED"}
cleanup_patterns = (
"model.obj",
"model.mtl",
"model.rtm",
"sky.rad",
"materials.rad",
"scene.rad",
"ascene.oct",
"mascene.oct",
"ascene.amb",
)
cleanup_extensions = (".hdr", ".tiff", ".rad", ".dat")
cleanup_prefixes = ("instance_", "linked_", "blender_meshes")
removed = 0
for filename in os.listdir(output_dir):
filepath = os.path.join(output_dir, filename)
if not os.path.isfile(filepath):
continue
is_generated = (
filename in cleanup_patterns
or os.path.splitext(filename)[1].lower() in cleanup_extensions
or any(filename.startswith(p) for p in cleanup_prefixes)
)
if is_generated:
try:
os.remove(filepath)
removed += 1
except OSError as e:
print(f"Failed to remove {filepath}: {e}")
# Reset the global scene reference
shared.scene = None
self.report({"INFO"}, f"Cleaned up {removed} generated files from {output_dir}")
return {"FINISHED"}
+80
View File
@@ -0,0 +1,80 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import bpy
from bpy_extras.io_utils import ImportHelper
import bonsai.tool as tool
class AddIESLight(bpy.types.Operator, ImportHelper):
"""Upload and add an IES light fixture to the scene"""
bl_idname = "radiance.add_ies_light"
bl_label = "Add IES Light"
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ies"
filter_glob: bpy.props.StringProperty(default="*.ies;*.IES", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
# Create new IES light entry
ies_light = props.ies_lights.add()
# Store as relative path if the blend file is saved
if bpy.data.filepath:
ies_light.ies_file_path = bpy.path.relpath(self.filepath)
else:
ies_light.ies_file_path = self.filepath
ies_light.rotation_z = 0.0
ies_light.is_enabled = True
# Set as active
props.active_ies_light_index = len(props.ies_lights) - 1
self.report({"INFO"}, f"Added IES light: {Path(self.filepath).name}")
return {"FINISHED"}
class RemoveIESLight(bpy.types.Operator):
"""Remove an IES light fixture mapping"""
bl_idname = "radiance.remove_ies_light"
bl_label = "Remove IES Light"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
if 0 <= self.index < len(props.ies_lights):
props.ies_lights.remove(self.index)
# Adjust active index if needed
if props.active_ies_light_index >= len(props.ies_lights):
props.active_ies_light_index = len(props.ies_lights) - 1
self.report({"INFO"}, "IES light removed")
return {"FINISHED"}
self.report({"WARNING"}, "Invalid IES light index")
return {"CANCELLED"}
+40 -5
View File
@@ -18,21 +18,18 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING
import bpy
if TYPE_CHECKING:
from bonsai.bim.module.light.prop import (
IESLight,
RadianceExporterProperties,
RadianceMaterial,
)
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb = json.load(f)
class MATERIAL_UL_radiance_materials(bpy.types.UIList):
def draw_item(
@@ -64,3 +61,41 @@ class MATERIAL_UL_radiance_materials(bpy.types.UIList):
op.material_index = index
else:
row.label(text="Not Mapped (White)")
class MATERIAL_UL_ies_lights(bpy.types.UIList):
"""UIList for displaying IES light fixtures."""
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data: RadianceExporterProperties,
item: IESLight,
icon,
active_data,
active_propname,
index,
) -> None:
if self.layout_type in {"DEFAULT", "COMPACT"}:
row = layout.row(align=True)
# Enable/disable checkbox (visible toggle)
row.prop(item, "is_enabled", text="", emboss=True)
# IES file name
if item.ies_file_path:
filename = Path(item.ies_file_path).name
row.label(text=filename, icon="FILE")
else:
row.label(text="(No file selected)", icon="ERROR")
# Target: collection or single object
if item.use_collection:
row.prop(item, "target_collection", text="", icon="OUTLINER_COLLECTION", emboss=False)
else:
row.prop(item, "target_object", text="", emboss=False)
# Remove button (X icon - negative action)
op = row.operator("radiance.remove_ies_light", text="", icon="X")
op.index = index
@@ -0,0 +1,136 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import webbrowser
import bpy
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.tool as tool
class RefreshIFCMaterials(bpy.types.Operator):
bl_idname = "bim.refresh_ifc_materials"
bl_label = "Refresh IFC Materials"
bl_description = "Refresh the list of IFC materials for mapping"
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded in Bonsai.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
ifc_file = tool.Ifc.get()
props.materials.clear()
for style in ifc_file.by_type("IfcSurfaceStyle"):
for render_item in style.Styles:
if render_item.is_a("IfcSurfaceStyleRendering"):
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
style_name = style.Name or f"Unnamed Style {render_item.id()}"
# Extract color
color = (1.0, 1.0, 1.0) # Default white
if render_item.SurfaceColour:
color = (
render_item.SurfaceColour.Red,
render_item.SurfaceColour.Green,
render_item.SurfaceColour.Blue,
)
material = props.add_material_mapping(style_id, style_name)
material.color = color
material.category = ""
material.subcategory = ""
material.is_mapped = False
props.active_material_index = 0 if props.materials else -1
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
return {"FINISHED"}
class UnmapMaterial(bpy.types.Operator):
bl_idname = "bim.unmap_material"
bl_label = "Unmap Material"
bl_options = {"REGISTER", "UNDO"}
material_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
material = props.materials[self.material_index]
props.unmap_material(material.name)
return {"FINISHED"}
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
bl_idname = "radiance.export_material_mappings"
bl_label = "Export Material Mappings"
bl_description = "Export material mappings to a JSON file"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
mappings = {}
for material in props.materials:
if material.is_mapped:
mappings[material.style_id] = {
"name": material.name,
"category": material.category,
"subcategory": material.subcategory,
}
with open(self.filepath, "w") as f:
json.dump(mappings, f, indent=4)
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
bl_idname = "radiance.import_material_mappings"
bl_label = "Import Material Mappings"
bl_description = "Import material mappings from a JSON file"
filename_ext = ".json"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.import_mappings(self.filepath)
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
bl_idname = "radiance.open_spectraldb"
bl_label = "Open SpectralDB"
bl_description = "Open the SpectralDB website for reference"
def execute(self, context):
webbrowser.open("https://spectraldb.com")
return {"FINISHED"}
+41 -713
View File
@@ -16,716 +16,44 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import math
import multiprocessing
import os
import time
import webbrowser
from datetime import datetime
from math import radians
from pathlib import Path
from typing import TYPE_CHECKING, Union
import bpy
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.geolocation
import pyradiance as pr
import requests
from bpy_extras.io_utils import ExportHelper, ImportHelper
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
ifc_materials = []
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb = json.load(f)
class ExportOBJ(bpy.types.Operator):
"""Exports the IFC File to OBJ"""
bl_idname = "export_scene.radiance"
bl_label = "Export"
bl_description = "Export the IFC to OBJ"
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.should_load_from_memory and not props.ifc_file:
cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.")
return False
return True
def execute(self, context):
# Get the output directory
props = tool.Blender.get_radiance_exporter_props()
should_load_from_memory = props.should_load_from_memory
output_dir = props.output_dir
props.is_exporting = True
# Conversion from IFC to OBJ
# Settings for obj
settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
settings.set("apply-default-materials", True)
serializer_settings.set("use-element-guids", True)
settings.set("use-world-coords", True)
ifc_file: ifcopenshell.file
if should_load_from_memory:
ifc_file = tool.Ifc.get()
else:
ifc_file_path = props.ifc_file
ifc_file = ifcopenshell.open(ifc_file_path)
obj_file_path = os.path.join(output_dir, "model.obj")
mtl_file_path = os.path.join(output_dir, "model.mtl")
serialiser = ifcopenshell.geom.serializers.obj(obj_file_path, mtl_file_path, settings, serializer_settings)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
if ifc_file.schema in ("IFC2X3", "IFC4"):
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
else:
elements = ifc_file.by_type("IfcElement")
elements += ifc_file.by_type("IfcSite")
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
if iterator.initialize():
while True:
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
materials = shape.geometry.materials
for material in materials:
ifc_materials.append(material.name)
serialiser.write(shape)
if not iterator.next():
break
serialiser.finalize()
props.is_exporting = False
self.report({"INFO"}, "Exported OBJ file to: {}".format(obj_file_path))
return {"FINISHED"}
class RadianceRender(bpy.types.Operator):
"""Radiance Rendering"""
bl_idname = "render_scene.radiance"
bl_label = "Render"
bl_description = "Renders the scene using Radiance"
def execute(self, context):
print("Starting Radiance rendering process...")
props = tool.Blender.get_radiance_exporter_props()
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
assert context.scene
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
aspect_ratio = resolution_x / resolution_y
quality = props.radiance_quality.upper()
detail = props.radiance_detail.upper()
variability = props.radiance_variability.upper()
output_dir = props.output_dir
output_file_name = props.output_file_name
output_file_format = props.output_file_format
use_hdr = props.use_hdr
choose_hdr_image = props.choose_hdr_image
print(f"Resolution: {resolution_x}x{resolution_y}")
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
print(f"Output directory: {output_dir}")
if use_hdr:
hdr_image = "noon_grass_2k.hdr"
hdr_mask = "noon_grass_2k_mask.hdr"
sky_map_cal = "skymap.cal"
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_image)
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_mask)
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", sky_map_cal)
obj_file_path = os.path.join(output_dir, "model.obj")
sun_props = tool.Blender.get_solar_props()
sun_pos_props = tool.Blender.get_sun_props()
assert sun_pos_props
sky_file_path = os.path.join(output_dir, "sky.rad")
# latitude = sun_props.latitude
# longitude = sun_props.longitude
# month = sun_props.month
# day = sun_props.day
# hour = sun_props.hour
# minute = sun_props.minute
# print("Sun Properties:")
# print("Latitude: ", latitude)
# print("Longitude: ", longitude)
# print("Timezone: ", timezone)
# print("Month: ", month)
# print("Day: ", day)
# print("Hour: ", hour)
# print("Minute: ", minute)
print("Setting up camera...")
if props.use_active_camera:
camera = context.scene.camera
else:
camera = props.selected_camera
if camera is None:
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
return {"CANCELLED"}
# Get camera position and direction
camera_position, camera_direction = self.get_camera_data(camera)
print(f"Camera position: {camera_position}")
print(f"Camera direction: {camera_direction}")
# sun_position = tool.Blender.get_addon("sun_position")
# azimuth, elevation = sun_position.sun_calc.get_sun_coordinates(
# sun_pos_props.time,
# sun_pos_props.latitude,
# sun_pos_props.longitude,
# -sun_pos_props.UTC_zone,
# sun_pos_props.month,
# sun_pos_props.day,
# sun_pos_props.year,
# )
dt = datetime(sun_pos_props.year, sun_props.month, sun_props.day, sun_props.hour, sun_props.minute)
sky_description = pr.gensky(
dt=dt,
# azimuth=64.1,
# altitude=-26.6,
latitude=sun_props.latitude,
longitude=sun_props.longitude,
year=sun_pos_props.year,
timezone=-int(sun_props.UTC_zone),
# sunny_with_sun=False,
# sunny_without_sun=False,
# cloudy=False,
# ground_reflectance=0.2,
# turbidity=3.0,
)
sky_description_str = sky_description.decode("utf-8")
# Write all this to file
# skyfunc glow sky_glow
# 0
# 0
# 4 .9 .9 1.15 0
# sky_glow source sky
# 0
# 0
# 4 0 0 1 180
# skyfunc glow ground_glow
# 0
# 0
# 4 1.4 .9 .6 0
# ground_glow source ground
# 0
# 0
# 4 0 0 -1 180
if use_hdr and choose_hdr_image == "Noon":
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
f.write(
'''void colorpict env_map
7 red green blue "'''
+ hdr_image_path
+ '''" "'''
+ sky_map_cal_path
+ '''" map_u map_v
0
1 0.5
# This is a multiplier to colour balance the env map
# In this case, it provides a rough ground luminance from 3k-5k
env_map colorfunc env_colour
4 100 100 100 .
0
0
# .37 .57 1.5 is measured from a HDRI image
# It is multiplied by a factor such that grey(r,g,b) = 1
skyfunc colorfunc sky_colour
4 .64 .99 2.6 .
0
0
void mixpict composite
7 env_colour sky_colour grey "'''
+ hdr_mask_path
+ '''" "'''
+ sky_map_cal_path
+ """" map_u map_v
0
2 0.5 1
composite glow env_map_glow
0
0
4 1 1 1 0
env_map_glow source sky
0
0
4 0 0 1 180
env_colour glow ground_glow
0
0
4 1 1 1 0
ground_glow source ground
0
0
4 0 0 -1 180"""
)
elif not use_hdr:
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
props = tool.Blender.get_radiance_exporter_props()
data = props.get_mappings_dict()
materials_file = os.path.join(output_dir, "materials.rad")
written_materials = set()
all_materials = set(ifc_materials)
with open(materials_file, "w") as file:
# Write default materials
default_materials = [
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
# "void plastic blue_plastic\n0\n0\n5 0.1 0.2 0.8 0.05 0.1\n",
# "void plastic red_plastic\n0\n0\n5 0.8 0.1 0.2 0.05 0.1\n",
# "void metal silver_metal\n0\n0\n5 0.8 0.8 0.8 0.9 0.1\n",
# "void glass clear_glass\n0\n0\n3 0.96 0.96 0.96\n",
# "void light white_light\n0\n0\n3 1.0 1.0 1.0\n",
# "void trans olive_trans\n0\n0\n7 0.6 0.7 0.4 0.05 0.05 0.7 0.2\n",
]
for material in default_materials:
file.write(material)
written_materials.add(material.split()[2]) # Add material name to written set
for style_id in all_materials:
material = next((m for m in props.materials if m.style_id == style_id), None)
if material and material.is_mapped:
category, subcategory = material.category, material.subcategory
if category in spectraldb and subcategory in spectraldb[category]:
material_def = spectraldb[category][subcategory]
material_name = material_def.split()[2]
if material_name not in written_materials:
file.write(material_def + "\n")
written_materials.add(material_name)
file.write(f"inherit alias {style_id} {material_name}\n")
else:
file.write(f"inherit alias {style_id} white\n")
else:
# If the material is not mapped, alias it to white
file.write(f"inherit alias {style_id} white\n")
self.report({"INFO"}, f"Exported Materials Rad file to: {materials_file}")
# Run obj2mesh
rtm_file_path = os.path.join(output_dir, "model.rtm")
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
# subprocess.run(["obj2mesh", "-a", materials_file, obj_file_path, rtm_file_path])
self.report({"INFO"}, "obj2mesh output: {}".format(mesh_file_path))
scene_file = os.path.join(output_dir, "scene.rad")
with open(scene_file, "w") as file:
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
self.report({"INFO"}, "Exported Scene file to: {}".format(scene_file))
print("Setting up Radiance scene...")
scene = pr.Scene("ascene")
material_path = os.path.join(output_dir, "materials.rad")
scene_path = os.path.join(output_dir, "scene.rad")
scene.add_material(material_path)
scene.add_surface(scene_path)
scene.add_source(sky_file_path)
print("Setting up view...")
assert isinstance(camera.data, bpy.types.Camera)
if camera.data.type == "PERSP":
# Perspective camera
camera_fov = camera.data.angle
# Calculate vertical FOV based on the desired aspect ratio
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
aview = pr.View(
vtype="v", # Perspective view
position=camera_position,
direction=camera_direction,
vup=(0, 0, 1), # Assuming Z is up
horiz=math.degrees(camera_fov),
vert=math.degrees(vertical_fov),
)
else: # 'ORTHO'
# Orthographic camera
# Calculate the view size based on the camera's orthographic scale
ortho_scale = camera.data.ortho_scale
view_width = ortho_scale
view_height = ortho_scale / aspect_ratio
aview = pr.View(
vtype="l", # Parallel projection (orthographic)
position=camera_position,
direction=camera_direction,
vup=(0, 0, 1), # Assuming Z is up
horiz=view_width,
vert=view_height,
)
scene.add_view(aview)
print("Starting render...")
start_time = time.time()
image = pr.render(
scene,
ambbounce=1,
resolution=(resolution_x, resolution_y),
quality=quality,
detail=detail,
variability=variability,
nproc=multiprocessing.cpu_count(),
)
end_time = time.time()
print(f"Render completed in {end_time - start_time:.2f} seconds")
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.{output_file_format.lower()}")
print(f"Saving HDR output to: {output_hdr_path}")
if output_file_format == "HDR":
with open(output_hdr_path, "wb") as wtr:
wtr.write(image)
else:
pass
print("Applying tone mapping...")
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
print(f"Saving TIFF output to: {tiff_path}")
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
print("Radiance rendering process completed successfully.")
self.report({"INFO"}, "Radiance rendering completed. Output: {}".format(tiff_path))
return {"FINISHED"}
def get_active_camera(self, context):
props = tool.Blender.get_radiance_exporter_props()
if props.use_active_camera:
return context.scene.camera
else:
return props.selected_camera
def get_camera_data(self, camera):
# Get camera position
position = camera.matrix_world.to_translation()
# Get camera direction
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
direction.normalize()
return (position.x, position.y, position.z), (direction.x, direction.y, direction.z)
def getResolution(self, context):
props = tool.Blender.get_radiance_exporter_props()
resolution_x = props.radiance_resolution_x
resolution_y = props.radiance_resolution_y
return resolution_x, resolution_y
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
output_bytes = pr.obj2mesh(inp, **kwargs)
with open(output_file, "wb") as f:
f.write(output_bytes)
return output_file
class ImportTrueNorth(bpy.types.Operator):
bl_idname = "bim.import_true_north"
bl_label = "Import True North"
bl_description = "Imports the True North from your IFC geometric context"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not SolarData.is_loaded:
SolarData.load()
return SolarData.data["true_north"] is not None
def execute(self, context):
props = tool.Blender.get_solar_props()
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if not context.TrueNorth:
continue
value = context.TrueNorth.DirectionRatios
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
return {"FINISHED"}
class ImportLatLong(bpy.types.Operator):
bl_idname = "bim.import_lat_long"
bl_label = "Import Latitude / Longitude"
bl_description = "Imports the latitude / longitude from an IfcSite"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
site = tool.Ifc.get().by_id(int(props.sites))
if site.RefLatitude and site.RefLongitude:
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
return {"FINISHED"}
class MoveSunPathTo3DCursor(bpy.types.Operator):
bl_idname = "bim.move_sun_path_to_3d_cursor"
bl_label = "Move Sun Path To 3D Cursor"
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
assert context.scene
props.sun_path_origin = context.scene.cursor.location
tool.Blender.update_viewport()
return {"FINISHED"}
class ViewFromSun(bpy.types.Operator):
bl_idname = "bim.view_from_sun"
bl_label = "View From Sun"
bl_description = "Views your model as if you were looking from the perspective of the sun"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if not (camera := bpy.data.objects.get("SunPathCamera")):
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
assert isinstance(camera.data, bpy.types.Camera)
assert context.scene
camera.data.type = "ORTHO"
camera.data.ortho_scale = 100 # The default of 6m is too small
context.scene.collection.objects.link(camera)
tool.Blender.activate_camera(camera)
props = tool.Blender.get_solar_props()
props.hour = props.hour # Just to refresh camera position
return {"FINISHED"}
class LightPickCoordinates(bpy.types.Operator):
bl_idname = "bim.light_pick_coordinates"
bl_label = "Pick Coordinates"
bl_description = (
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
)
bl_options = {"REGISTER", "UNDO"}
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
use_current_location: bool
def invoke(self, context, event):
if event.alt:
self.use_current_location = True
return self.execute(context)
def execute(self, context):
props = tool.Blender.get_solar_props()
if not self.use_current_location:
zoom = 13.5
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
webbrowser.open(url)
return {"FINISHED"}
response = requests.get("http://ip-api.com/json/")
data = response.json()
props.latitude = data["lat"]
props.longitude = data["lon"]
return {"FINISHED"}
class LightSetTimeToNow(bpy.types.Operator):
bl_idname = "bim.light_set_time_to_now"
bl_label = "Now"
bl_description = "Set time to current local time."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
props.set_from_datetime(datetime.now())
return {"FINISHED"}
class RefreshIFCMaterials(bpy.types.Operator):
bl_idname = "bim.refresh_ifc_materials"
bl_label = "Refresh IFC Materials"
bl_description = "Refresh the list of IFC materials for mapping"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
ifc_file: ifcopenshell.file
ifc_file = tool.Ifc.get() if props.should_load_from_memory else ifcopenshell.open(props.ifc_file)
props.materials.clear()
for style in ifc_file.by_type("IfcSurfaceStyle"):
for render_item in style.Styles:
if render_item.is_a("IfcSurfaceStyleRendering"):
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
style_name = style.Name or f"Unnamed Style {render_item.id()}"
# Extract color and transparency
color = (1.0, 1.0, 1.0) # Default white
transparency = 0.0 # Default opaque
if render_item.SurfaceColour:
color = (
render_item.SurfaceColour.Red,
render_item.SurfaceColour.Green,
render_item.SurfaceColour.Blue,
)
if hasattr(render_item, "Transparency") and render_item.Transparency is not None:
transparency = render_item.Transparency
# Add material with color
material = props.add_material_mapping(style_id, style_name)
material.color = color
# If transparency is high, consider it as glass
if transparency > 0.5:
material.category = "Glass"
material.subcategory = "Clear Glass"
material.is_mapped = True
props.active_material_index = 0 if props.materials else -1
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
return {"FINISHED"}
class UnmapMaterial(bpy.types.Operator):
bl_idname = "bim.unmap_material"
bl_label = "Unmap Material"
bl_options = {"REGISTER", "UNDO"}
material_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
material = props.materials[self.material_index]
props.unmap_material(material.name)
return {"FINISHED"}
class RADIANCE_OT_select_camera(bpy.types.Operator):
bl_idname = "radiance.select_camera"
bl_label = "Select Camera"
bl_description = "Select a camera from the viewport"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == "CAMERA"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.selected_camera = context.object
props.use_active_camera = False
return {"FINISHED"}
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
bl_idname = "radiance.export_material_mappings"
bl_label = "Export Material Mappings"
bl_description = "Export material mappings to a JSON file"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
mappings = {}
for material in props.materials:
if material.is_mapped:
mappings[material.style_id] = {
"name": material.name,
"category": material.category,
"subcategory": material.subcategory,
}
with open(self.filepath, "w") as f:
json.dump(mappings, f, indent=4)
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
bl_idname = "radiance.import_material_mappings"
bl_label = "Import Material Mappings"
bl_description = "Import material mappings from a JSON file"
filename_ext = ".json"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.import_mappings(self.filepath)
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
bl_idname = "radiance.open_spectraldb"
bl_label = "Open SpectralDB"
bl_description = "Open the SpectralDB website for reference"
def execute(self, context):
webbrowser.open("https://spectraldb.com")
return {"FINISHED"}
"""Thin re-export module for backward compatibility.
All operator classes are defined in their respective submodules:
- export.py: ExportOBJ, CleanupRadianceFiles
- prepare.py: PrepareRadianceScene
- render.py: RadianceRender, FalseColorRadiance, RADIANCE_OT_select_camera
- solar.py: ImportTrueNorth, ImportLatLong, MoveSunPathTo3DCursor,
ViewFromSun, LightPickCoordinates, LightSetTimeToNow
- material.py: RefreshIFCMaterials, UnmapMaterial,
RADIANCE_OT_export_material_mappings,
RADIANCE_OT_import_material_mappings,
RADIANCE_OT_open_spectraldb
- ies.py: AddIESLight, RemoveIESLight
EnumPropertySearch and SetEnumProperty are provided by the global
bonsai.bim.operator module (bl_idname "bim.enum_property_search").
"""
from bonsai.bim.module.light.export import CleanupRadianceFiles, ExportOBJ # noqa: F401
from bonsai.bim.module.light.ies import AddIESLight, RemoveIESLight # noqa: F401
from bonsai.bim.module.light.material import ( # noqa: F401
RADIANCE_OT_export_material_mappings,
RADIANCE_OT_import_material_mappings,
RADIANCE_OT_open_spectraldb,
RefreshIFCMaterials,
UnmapMaterial,
)
from bonsai.bim.module.light.prepare import PrepareRadianceScene # noqa: F401
from bonsai.bim.module.light.render import ( # noqa: F401
FalseColorRadiance,
RADIANCE_OT_select_camera,
RadianceRender,
)
from bonsai.bim.module.light.solar import ( # noqa: F401
ImportLatLong,
ImportTrueNorth,
LightPickCoordinates,
LightSetTimeToNow,
MoveSunPathTo3DCursor,
ViewFromSun,
)
@@ -0,0 +1,741 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import math
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Union
import bpy
import pyradiance as pr
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.light.prop import spectraldb
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
def _matrix_to_xform_args(matrix: list[list[float]]) -> str:
"""Decompose a 4x4 matrix into Radiance xform arguments.
Decomposes into Z/Y/X Euler rotations + translation.
The matrix is expected in row-major format (Blender convention).
xform applies transforms right-to-left, so we write: -rx X -ry Y -rz Z -t tx ty tz
"""
from mathutils import Matrix as MMatrix
m = MMatrix(matrix)
translation = m.to_translation()
euler = m.to_euler("XYZ")
parts = []
rx = math.degrees(euler.x)
ry = math.degrees(euler.y)
rz = math.degrees(euler.z)
if abs(rx) > 1e-6:
parts.append(f"-rx {rx:.6f}")
if abs(ry) > 1e-6:
parts.append(f"-ry {ry:.6f}")
if abs(rz) > 1e-6:
parts.append(f"-rz {rz:.6f}")
parts.append(f"-t {translation.x:.6f} {translation.y:.6f} {translation.z:.6f}")
return " ".join(parts)
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
try:
output_bytes = pr.obj2mesh(inp, **kwargs)
with open(output_file, "wb") as f:
f.write(output_bytes)
return output_file
except Exception as e:
print(f"ERROR in obj2mesh conversion:")
print(f" Input file: {inp}")
print(f" Output file: {output_file}")
print(f" Additional args: {kwargs}")
print(f" Error: {str(e)}")
raise
def convert_ies_to_radiance(
ies_file_path: str,
output_dir: str,
lamp_type: str = "",
lamp_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
multiply_factor: float = 1.0,
radius: float = 0.0,
) -> tuple[str, str]:
"""Convert IES file to Radiance format using pyradiance.
Args:
ies_file_path: Path to the .ies file
output_dir: Directory where .rad and .dat files will be saved
lamp_type: Type of lamp (e.g., 'LED', 'metal halide')
lamp_color: RGB color tuple (0.0-1.0 each)
multiply_factor: Brightness multiplier (0.1-10.0)
radius: Illum sphere radius (0 = use IES geometry)
Returns:
Tuple of (rad_file_path, dat_file_path)
"""
try:
ies_path = Path(bpy.path.abspath(ies_file_path))
base_name = ies_path.stem
output_path = os.path.join(output_dir, base_name)
kwargs = {"outname": output_path}
if lamp_type:
kwargs["lamp_type"] = lamp_type
if lamp_color != (1.0, 1.0, 1.0):
kwargs["lamp_color"] = lamp_color
if multiply_factor != 1.0:
kwargs["multiply_factor"] = multiply_factor
if radius > 0.0:
kwargs["radius"] = radius
pr.ies2rad(ies_path, **kwargs)
rad_file = os.path.join(output_dir, f"{base_name}.rad")
dat_file = os.path.join(output_dir, f"{base_name}.dat")
return rad_file, dat_file
except Exception as e:
raise RuntimeError(f"Failed to convert IES file '{ies_file_path}': {str(e)}")
class PrepareRadianceScene(bpy.types.Operator):
"""Prepares the Radiance scene (runs heavy work in background thread)"""
bl_idname = "scene.prepare_radiance"
bl_label = "Prepare Radiance Scene"
bl_description = "Prepares the Radiance scene by creating necessary files and setting up the view"
_timer = None
_thread: Union[threading.Thread, None] = None
_error: Union[str, None] = None
_start_time: float = 0.0
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
if props.is_preparing:
cls.poll_message_set("Scene preparation is already in progress.")
return False
return True
def get_camera_data(self, camera):
position = camera.matrix_world.to_translation()
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
direction.normalize()
up = camera.matrix_world.to_quaternion() @ Vector((0, 1, 0))
up.normalize()
if abs(direction.dot(up)) > 0.99:
if abs(direction.dot(Vector((0, 1, 0)))) > 0.99:
reference = Vector((1, 0, 0))
else:
reference = Vector((0, 1, 0))
right = direction.cross(reference)
if right.length < 0.01:
reference = Vector((0, 0, 1))
right = direction.cross(reference)
right.normalize()
up = right.cross(direction)
up.normalize()
if up.length < 0.01:
up = Vector((0, 0, 1))
else:
up.normalize()
return (
(position.x, position.y, position.z),
(direction.x, direction.y, direction.z),
(up.x, up.y, up.z),
)
def execute(self, context):
print("Starting Radiance scene preparation...")
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
obj_file_path = os.path.join(output_dir, "model.obj")
if not os.path.exists(obj_file_path):
error_msg = "OBJ file not found. Please run 'Export Geometry for Simulation' first."
self.report({"ERROR"}, error_msg)
print(f"ERROR: {error_msg}")
print(f" Expected file: {obj_file_path}")
return {"CANCELLED"}
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
assert context.scene
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
aspect_ratio = resolution_x / resolution_y
use_hdr = props.use_hdr
choose_hdr_image = props.choose_hdr_image
print(f"Resolution: {resolution_x}x{resolution_y}")
print(f"Output directory: {output_dir}")
print(f"Found OBJ file: {obj_file_path} ({os.path.getsize(obj_file_path)} bytes)")
hdr_image_path = ""
hdr_mask_path = ""
sky_map_cal_path = ""
if use_hdr:
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k.hdr")
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k_mask.hdr")
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", "skymap.cal")
sky_file_path = os.path.join(output_dir, "sky.rad")
print("Setting up camera...")
if props.use_active_camera:
camera = context.scene.camera
else:
camera = props.selected_camera
if camera is None:
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
return {"CANCELLED"}
camera_position, camera_direction, camera_up = self.get_camera_data(camera)
camera_type = camera.data.type
camera_fov = camera.data.angle if camera_type == "PERSP" else 0.0
camera_ortho_scale = camera.data.ortho_scale if camera_type == "ORTHO" else 0.0
print(f"Camera position: {camera_position}")
print(f"Camera direction: {camera_direction}")
print(f"Camera up: {camera_up}")
# Collect sky generation data (must be on main thread)
sky_data = None
if props.use_sun:
sun_props = tool.Blender.get_solar_props()
sun_pos_props = tool.Blender.get_sun_props()
if not sun_pos_props:
self.report(
{"ERROR"}, "Sun position addon not available. Enable 'Sun Position' addon or disable 'Use Sun'."
)
return {"CANCELLED"}
sky_data = {
"year": sun_props.year,
"month": sun_props.month,
"day": sun_props.day,
"hour": sun_props.hour,
"minute": sun_props.minute,
"latitude": sun_props.latitude,
"longitude": sun_props.longitude,
"UTC_zone": sun_props.UTC_zone,
"sun_year": sun_pos_props.year,
"sky_condition": props.sky_condition,
"ground_reflectance": props.ground_reflectance,
"turbidity": props.turbidity,
}
# Collect IES light data (must be on main thread)
ies_light_data = []
for idx, ies_light in enumerate(props.ies_lights):
if not ies_light.is_enabled or not ies_light.ies_file_path:
ies_light_data.append(None)
continue
target_empties = ies_light.get_target_empties()
if not target_empties:
ies_light_data.append(None)
continue
positions = []
for obj in target_empties:
try:
positions.append((obj.location.x, obj.location.y, obj.location.z))
except ReferenceError:
continue
if not positions:
ies_light_data.append(None)
continue
ies_light_data.append(
{
"ies_file_path": ies_light.ies_file_path,
"lamp_type": ies_light.lamp_type,
"lamp_color": ies_light.lamp_color,
"multiply_factor": ies_light.multiply_factor,
"radius": ies_light.radius,
"rotation_z": ies_light.rotation_z,
"positions": positions,
"is_enabled": ies_light.is_enabled,
}
)
# Collect material mapping data (must be on main thread)
material_mappings = []
for m in props.materials:
material_mappings.append(
{
"style_id": m.style_id,
"is_mapped": m.is_mapped,
"category": m.category,
"subcategory": m.subcategory,
}
)
linked_exports_snapshot = list(linked_model_exports)
# All Blender data collected — now launch background thread
props.is_preparing = True
self._start_time = time.time()
self._error = None
import bonsai.bim.module.light.shared as shared
shared.scene = None
self._thread = threading.Thread(
target=self._prepare_worker,
args=(
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
),
daemon=True,
)
self._thread.start()
wm = context.window_manager
self._timer = wm.event_timer_add(0.5, window=context.window)
wm.modal_handler_add(self)
self.report({"INFO"}, "Scene preparation started in background...")
context.window.cursor_set("WAIT")
return {"RUNNING_MODAL"}
def _prepare_worker(
self,
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
):
"""Runs in a background thread — no Blender API calls allowed here."""
try:
self._do_prepare(
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
)
except Exception as e:
self._error = str(e)
import traceback
traceback.print_exc()
def _do_prepare(
self,
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
):
"""The actual preparation logic (no Blender API)."""
import bonsai.bim.module.light.shared as shared
# --- Generate sky file ---
if sky_data is not None:
dt = datetime(sky_data["year"], sky_data["month"], sky_data["day"], sky_data["hour"], sky_data["minute"])
print(f"Sun position data for Radiance gensky:")
print(f" DateTime: {dt}")
print(f" Latitude: {sky_data['latitude']}°")
print(f" Longitude: {sky_data['longitude']}°")
sky_condition = sky_data["sky_condition"]
longitude_for_gensky = -sky_data["longitude"]
timezone_for_gensky = -int(sky_data["UTC_zone"]) * 15
sky_description = pr.gensky(
dt=dt,
latitude=sky_data["latitude"],
longitude=longitude_for_gensky,
year=sky_data["sun_year"],
timezone=timezone_for_gensky,
sunny_with_sun=sky_condition == "SUNNY_WITH_SUN",
sunny_without_sun=sky_condition == "SUNNY_WITHOUT_SUN",
cloudy=sky_condition == "CLOUDY",
ground_reflectance=sky_data["ground_reflectance"],
turbidity=sky_data["turbidity"],
)
sky_description_str = sky_description.decode("utf-8")
if use_hdr and choose_hdr_image == "Noon":
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
f.write(
'''void colorpict env_map
7 red green blue "'''
+ hdr_image_path
+ '''" "'''
+ sky_map_cal_path
+ '''" map_u map_v
0
1 0.5
# This is a multiplier to colour balance the env map
# In this case, it provides a rough ground luminance from 3k-5k
env_map colorfunc env_colour
4 100 100 100 .
0
0
# .37 .57 1.5 is measured from a HDRI image
# It is multiplied by a factor such that grey(r,g,b) = 1
skyfunc colorfunc sky_colour
4 .64 .99 2.6 .
0
0
void mixpict composite
7 env_colour sky_colour grey "'''
+ hdr_mask_path
+ '''" "'''
+ sky_map_cal_path
+ """" map_u map_v
0
2 0.5 1
composite glow env_map_glow
0
0
4 1 1 1 0
env_map_glow source sky
0
0
4 0 0 1 180
env_colour glow ground_glow
0
0
4 1 1 1 0
ground_glow source ground
0
0
4 0 0 -1 180"""
)
elif not use_hdr:
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
else:
print("Skipping sky generation (use_sun is False)...")
# --- Write materials.rad ---
materials_file = os.path.join(output_dir, "materials.rad")
written_materials = set()
all_materials = set(ifc_materials)
with open(materials_file, "w") as file:
default_materials = [
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
]
for material in default_materials:
file.write(material)
written_materials.add(material.split()[2])
for style_id in all_materials:
mat_data = next((m for m in material_mappings if m["style_id"] == style_id), None)
if mat_data and mat_data["is_mapped"]:
category, subcategory = mat_data["category"], mat_data["subcategory"]
if category in spectraldb and subcategory in spectraldb[category]:
material_def = spectraldb[category][subcategory]
material_name = material_def.split()[2]
if material_name not in written_materials:
file.write(material_def + "\n")
written_materials.add(material_name)
file.write(f"inherit alias {style_id} {material_name}\n")
else:
file.write(f"inherit alias {style_id} white\n")
else:
file.write(f"inherit alias {style_id} white\n")
print(f"Exported Materials Rad file to: {materials_file}")
# --- Convert IES light files ---
print("Processing IES light files...")
converted_ies_lights = {}
for idx, ies_data in enumerate(ies_light_data):
if ies_data is None:
continue
try:
rad_file, dat_file = convert_ies_to_radiance(
ies_data["ies_file_path"],
output_dir,
lamp_type=ies_data["lamp_type"],
lamp_color=ies_data["lamp_color"],
multiply_factor=ies_data["multiply_factor"],
radius=ies_data["radius"],
)
converted_ies_lights[idx] = (rad_file, dat_file)
print(f" Converted IES light {idx}: {Path(ies_data['ies_file_path']).name}")
except Exception as e:
print(f" ERROR converting IES light {idx}: {str(e)}")
# --- Convert OBJ to RTM ---
print(f"OBJ file size: {os.path.getsize(obj_file_path)} bytes")
print(f"Materials file size: {os.path.getsize(materials_file)} bytes")
print(f"Converting OBJ to RTM format...")
rtm_file_path = os.path.join(output_dir, "model.rtm")
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
print(f"obj2mesh output: {mesh_file_path}")
# Convert linked model OBJs to RTM
linked_rtm_files = []
for link_idx, (link_obj_path, link_mtl_path, link_matrix) in enumerate(linked_exports_snapshot):
if not os.path.exists(link_obj_path):
print(f"Linked model OBJ not found: {link_obj_path}")
continue
link_rtm_path = os.path.join(output_dir, f"linked_{link_idx}.rtm")
try:
save_obj2mesh_output(link_obj_path, link_rtm_path, matfiles=[materials_file])
linked_rtm_files.append((link_rtm_path, link_matrix))
print(f"Converted linked model {link_idx} to RTM")
except Exception as e:
print(f"Failed to convert linked model {link_idx} to RTM: {e}")
# --- Write scene.rad ---
scene_file = os.path.join(output_dir, "scene.rad")
with open(scene_file, "w") as file:
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
file.write("\n")
if linked_rtm_files:
file.write("\n# Linked Models\n")
for link_idx, (link_rtm_path, link_matrix) in enumerate(linked_rtm_files):
is_identity = all(
abs(link_matrix[i][j] - (1.0 if i == j else 0.0)) < 1e-6 for i in range(4) for j in range(4)
)
if is_identity:
file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n\n')
print(f"Added linked model {link_idx} to scene (identity, inline mesh)")
else:
link_rad_path = os.path.join(output_dir, f"linked_{link_idx}.rad")
with open(link_rad_path, "w") as link_file:
link_file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n')
xform_args = _matrix_to_xform_args(link_matrix)
file.write(f'!xform {xform_args} "{link_rad_path}"\n')
print(f"Added linked model {link_idx} to scene with xform: {xform_args}")
# IES light fixtures
if ies_light_data:
file.write("\n# IES Light Fixtures\n")
for idx, ies_data in enumerate(ies_light_data):
if ies_data is None:
continue
z_rot = math.degrees(ies_data["rotation_z"])
for pos in ies_data["positions"]:
if idx in converted_ies_lights:
rad_path = converted_ies_lights[idx][0]
rad_filename = Path(rad_path).name
file.write(f'!xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
else:
rad_base = Path(ies_data["ies_file_path"]).stem
rad_filename = f"{rad_base}.rad"
file.write(f'# !xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
print(f"Exported Scene file to: {scene_file}")
# --- Validate light sources ---
has_sky = sky_data is not None
has_ies_lights = len(converted_ies_lights) > 0
if not has_sky and not has_ies_lights:
raise RuntimeError("No light sources available. Please enable 'Use Sun' or add and map IES light fixtures.")
# --- Build pr.Scene ---
print("Setting up Radiance scene...")
new_scene = pr.Scene("ascene")
material_path = os.path.join(output_dir, "materials.rad")
scene_path = os.path.join(output_dir, "scene.rad")
new_scene.add_material(material_path)
new_scene.add_surface(scene_path)
if has_sky:
new_scene.add_source(sky_file_path)
print(f"Added sky light source")
if has_ies_lights:
print(f"Added {len(converted_ies_lights)} IES light source(s)")
print("Setting up view...")
if camera_type == "PERSP":
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
aview = pr.create_default_view()
aview.type = "v"
aview.vp = camera_position
aview.vdir = camera_direction
aview.vu = camera_up
aview.horiz = math.degrees(camera_fov)
aview.vert = math.degrees(vertical_fov)
else:
view_width = camera_ortho_scale
view_height = camera_ortho_scale / aspect_ratio
aview = pr.create_default_view()
aview.type = "l"
aview.vp = camera_position
aview.vdir = camera_direction
aview.vu = camera_up
aview.horiz = view_width
aview.vert = view_height
new_scene.add_view(aview)
# Set the global scene reference (thread-safe assignment)
shared.scene = new_scene
print("Scene preparation complete.")
def modal(self, context, event):
if event.type == "TIMER":
if self._thread is not None and self._thread.is_alive():
return {"RUNNING_MODAL"}
# Thread finished — clean up
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_preparing = False
context.window.cursor_set("DEFAULT")
if self._error:
self.report({"ERROR"}, f"Scene preparation failed: {self._error}")
return {"CANCELLED"}
elapsed = time.time() - self._start_time
self.report({"INFO"}, f"Radiance scene prepared successfully in {elapsed:.1f}s")
print(f"Scene preparation completed in {elapsed:.2f} seconds")
return {"FINISHED"}
elif event.type == "ESC":
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_preparing = False
context.window.cursor_set("DEFAULT")
self.report({"WARNING"}, "Scene preparation cannot be cancelled mid-operation")
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def _cleanup_timer(self, context):
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
+312 -29
View File
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import calendar
import datetime
import json
import os
@@ -43,7 +44,6 @@ from bonsai.bim.module.light.data import SolarData
from bonsai.bim.module.light.decorator import SolarDecorator
sun_position = tool.Blender.get_addon("sun_position")
now = datetime.datetime.now()
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb: dict[str, dict[str, str]] = json.load(f)
@@ -72,8 +72,8 @@ def update_coordinates(self: "BIMSolarProperties", context: bpy.types.Context) -
def update_latlong(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
sun_props = tool.Blender.get_sun_props()
assert sun_props
sun_props.longitude = sun_props.longitude
sun_props.latitude = sun_props.latitude
sun_props.longitude = self.longitude
sun_props.latitude = self.latitude
self["coordinates"] = sun_props.coordinates
update_sun_path(self)
@@ -135,6 +135,14 @@ def update_resolution(self: "RadianceExporterProperties", context: bpy.types.Con
context.scene.render.resolution_y = self.radiance_resolution_y
def update_day(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
"""Clamp the day to the valid range for the current month/year."""
max_day = calendar.monthrange(self.year, self.month)[1]
if self.day > max_day:
self["day"] = max_day
update_sun_path(self)
def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context, None] = None) -> None:
if not SolarData.is_loaded:
SolarData.load()
@@ -152,9 +160,13 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
sun_props.sun_distance = self.sun_path_size
sun_props.latitude = self.latitude
sun_props.longitude = self.longitude
# Clamp day to valid range for the current month/year
max_day = calendar.monthrange(self.year, self.month)[1]
day = min(self.day, max_day)
sun_props.year = self.year
sun_props.month = self.month
sun_props.day = self.day
sun_props.day = day
sun_props.time = self.hour + (self.minute / 60)
# Preserve IFC sign convention
sun_props.north_offset = self.true_north * -1
@@ -181,8 +193,11 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
)
sun_vector = sun_position.sun_calc.get_sun_vector(azimuth, elevation) * sun_props.sun_distance
props.sun_position = sun_vector
# sun_vector.z = max(0, sun_vector.z)
# Light direction is a bit weird?
# Update Blender viewport light direction for shadow visualization
# This coordinate transformation converts from sun_position addon's coordinate system
# to Blender's display.light_direction coordinate system
# Note: This only affects viewport shading, not the Radiance rendering
mat = Matrix(((-1.0, 0.0, 0.0, 0.0), (0.0, 0, 1.0, 0.0), (-0.0, -1.0, 0, 0.0), (0.0, 0.0, 0.0, 1.0))).inverted()
rotation_euler = Euler((elevation - pi / 2, 0, -azimuth))
rotation_quaternion = rotation_euler.to_quaternion()
@@ -193,6 +208,9 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
assert bpy.context.scene
assert bpy.context.scene.display
# Set viewport light direction based on sun position
# If sun is below horizon (z < 0), use default upward direction
# Otherwise, use calculated direction from sun position
if sun_vector.z < 0:
bpy.context.scene.display.light_direction = mat @ Vector((0, 0, 1))
else:
@@ -223,6 +241,106 @@ class RadianceMaterial(PropertyGroup):
color: tuple[float, float, float]
class IESLight(PropertyGroup):
"""Represents a mapping between an IES light file and scene Empty objects.
Supports two targeting modes:
- Object mode: target a single Empty object
- Collection mode: target all Empty objects in a collection
"""
ies_file_path: StringProperty(
name="IES File Path",
description="Path to the IES luminaire data file",
subtype="FILE_PATH",
default="",
)
use_collection: BoolProperty(
name="Use Collection",
description="Apply this light to all Empty objects in a collection instead of a single object",
default=False,
)
target_object: PointerProperty(
type=bpy.types.Object,
name="Target Object",
description="Empty object where the light fixture will be placed",
poll=lambda self, obj: obj.type == "EMPTY",
)
target_collection: PointerProperty(
type=bpy.types.Collection,
name="Target Collection",
description="Collection of Empty objects where the light fixture will be placed",
)
rotation_z: FloatProperty(
name="Rotation Z",
description="Rotation around Z-axis in degrees (-180 to 180)",
min=-180.0,
max=180.0,
default=0.0,
subtype="ANGLE",
)
is_enabled: BoolProperty(
name="Enabled",
description="Include this light in the Radiance export",
default=True,
)
# Additional lamp properties for customization
lamp_type: StringProperty(
name="Lamp Type",
description="Type of lamp (e.g., 'LED', 'metal halide', 'fluorescent')",
default="",
)
lamp_color: FloatVectorProperty(
name="Lamp Color",
description="Lamp color (RGB) for custom color adjustments",
subtype="COLOR",
default=(1.0, 1.0, 1.0),
min=0.0,
max=1.0,
size=3,
)
multiply_factor: FloatProperty(
name="Brightness Factor",
description="Multiply all output quantities by this factor (0.1 to 10.0)",
min=0.1,
max=10.0,
default=1.0,
)
radius: FloatProperty(
name="Illum Sphere Radius",
description="Radius of illum sphere (ignores geometry from IES file). 0 = use IES geometry",
min=0.0,
max=10.0,
default=0.0,
)
def get_target_empties(self) -> list[bpy.types.Object]:
"""Return all Empty objects this light targets."""
if self.use_collection and self.target_collection is not None:
return [obj for obj in self.target_collection.all_objects if obj.type == "EMPTY" and not obj.hide_get()]
elif not self.use_collection and self.target_object is not None:
try:
_ = self.target_object.name
if not self.target_object.hide_get():
return [self.target_object]
except ReferenceError:
pass
return []
if TYPE_CHECKING:
ies_file_path: str
use_collection: bool
target_object: Union[bpy.types.Object, None]
target_collection: Union[bpy.types.Collection, None]
rotation_z: float
is_enabled: bool
lamp_type: str
lamp_color: tuple[float, float, float]
multiply_factor: float
radius: float
class RadianceExporterProperties(PropertyGroup):
def update_output_dir(self, context) -> None:
@@ -233,6 +351,9 @@ class RadianceExporterProperties(PropertyGroup):
if self.ifc_file:
self.ifc_file = bpy.path.abspath(self.ifc_file)
def get_categories(self, context):
return sorted([(k, k, "") for k in spectraldb.keys()])
def add_material_mapping(self, style_id: str, style_name: str) -> RadianceMaterial:
item = self.materials.add()
item.name = style_name
@@ -247,7 +368,10 @@ class RadianceExporterProperties(PropertyGroup):
mappings = json.load(f)
for style_id, mapping in mappings.items():
material = self.get_material_mapping(mapping["name"])
# Try matching by style_id first, fall back to name
material = self.get_material_mapping_by_id(style_id)
if material is None:
material = self.get_material_mapping(mapping["name"])
if material:
material.style_id = style_id
material.category = mapping["category"]
@@ -259,11 +383,14 @@ class RadianceExporterProperties(PropertyGroup):
new_material.subcategory = mapping["subcategory"]
new_material.is_mapped = True
def get_material_mapping_by_id(self, style_id: str) -> Union[RadianceMaterial, None]:
return next((item for item in self.materials if item.style_id == style_id), None)
def get_material_mapping(self, style_name: str) -> Union[RadianceMaterial, None]:
return next((item for item in self.materials if item.name == style_name), None)
def set_material_mapping(self, style_id: str, style_name: str, category: str, subcategory: str) -> None:
item = self.get_material_mapping(style_name)
item = self.get_material_mapping_by_id(style_id) or self.get_material_mapping(style_name)
if item:
item.category = category
item.subcategory = subcategory
@@ -279,8 +406,12 @@ class RadianceExporterProperties(PropertyGroup):
if item.category and item.subcategory
}
def unmap_material(self, style_name: str) -> None:
item = self.get_material_mapping(style_name)
def unmap_material(self, style_name: str, style_id: str = "") -> None:
item = None
if style_id:
item = self.get_material_mapping_by_id(style_id)
if item is None:
item = self.get_material_mapping(style_name)
if item:
item.category = ""
item.subcategory = ""
@@ -290,6 +421,14 @@ class RadianceExporterProperties(PropertyGroup):
name="Is Exporting", description="Whether the OBJ export is in progress", default=False
)
is_preparing: bpy.props.BoolProperty(
name="Is Preparing", description="Whether scene preparation is in progress", default=False
)
is_rendering: bpy.props.BoolProperty(
name="Is Rendering", description="Whether a Radiance render is in progress", default=False
)
categories = [
("Wall", "Wall", ""),
("Floor", "Floor", ""),
@@ -316,16 +455,13 @@ class RadianceExporterProperties(PropertyGroup):
print(f"Material '{active_material.name}' mapped to {self.category} - {self.subcategory}")
category: bpy.props.EnumProperty(
items=categories, name="Category", description="Material category", update=update_material_mapping
items=get_categories, name="Category", description="Material category", update=update_material_mapping
)
def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
global SUBCATEGORIES_ENUM_ITEMS # ty: ignore[unresolved-global]
if self.category in spectraldb:
SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()]
else:
SUBCATEGORIES_ENUM_ITEMS = []
return SUBCATEGORIES_ENUM_ITEMS
return sorted([(k, k, "") for k in spectraldb[self.category].keys()])
return []
subcategory: bpy.props.EnumProperty(
items=get_subcategories, name="Subcategory", description="Material subcategory", update=update_material_mapping
@@ -334,12 +470,9 @@ class RadianceExporterProperties(PropertyGroup):
materials: CollectionProperty(type=RadianceMaterial)
active_material_index: IntProperty()
# material_mappings: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup, name="Material Mappings")
# material_mappings: CollectionProperty(type=MaterialMapping)
should_load_from_memory: BoolProperty(
name="Load from Memory",
default=False,
default=True,
)
radiance_resolution_x: IntProperty(
@@ -348,6 +481,13 @@ class RadianceExporterProperties(PropertyGroup):
radiance_resolution_y: IntProperty(
name="Y", description="Vertical resolution of the output image", default=1080, min=1, update=update_resolution
)
radiance_bin_dir: StringProperty(
name="Radiance Bin",
description="Path to the Radiance bin folder (containing falsecolor, pcomb, etc.)",
default="",
subtype="DIR_PATH",
)
output_dir: StringProperty(
name="Output Directory",
description="Directory to output Radiance files",
@@ -363,6 +503,15 @@ class RadianceExporterProperties(PropertyGroup):
update=lambda self, context: self.update_ifc_file(context),
)
ambient_bounces: IntProperty(
name="Ambient Bounces",
description="Number of indirect light bounces. Higher = more light fills dark areas but slower. "
"1 is minimal, 2-3 recommended for IES-only scenes, 4+ for complex interiors",
min=0,
max=8,
default=2,
)
radiance_quality: EnumProperty(
name="Quality",
description="Radiance rendering quality",
@@ -393,14 +542,15 @@ class RadianceExporterProperties(PropertyGroup):
name="Output File Format",
description="Format of the output image file",
items=[
("HDR", "HDR + Tiff", "High Dynamic Range"),
("HDR", "HDR", "High Dynamic Range (HDR) file only"),
("HDR_TIFF", "HDR + Tiff", "High Dynamic Range (HDR) and Tiff files"),
],
default="HDR",
default="HDR_TIFF",
)
use_hdr: BoolProperty(
name="Use HDR",
description="Use HDR image format",
name="HDR Environment Map",
description="Use an HDR image as the sky dome for realistic environment lighting and reflections",
default=True,
)
@@ -413,6 +563,40 @@ class RadianceExporterProperties(PropertyGroup):
default="Noon",
)
use_sun: BoolProperty(
name="Use Sun",
description="Use sun position data to generate sky. If disabled, generates a default sky without sun",
default=True,
)
# Sky generation parameters
sky_condition: EnumProperty(
name="Sky Condition",
description="Type of sky condition to generate",
items=[
("SUNNY_WITH_SUN", "Sunny with Sun", "Clear sky with direct sun"),
("SUNNY_WITHOUT_SUN", "Sunny without Sun", "Clear sky without direct sun"),
("CLOUDY", "Cloudy", "Overcast sky condition"),
],
default="SUNNY_WITH_SUN",
)
ground_reflectance: FloatProperty(
name="Ground Reflectance",
description="Ground reflectance value (0.0 to 1.0)",
min=0.0,
max=1.0,
default=0.2,
)
turbidity: FloatProperty(
name="Turbidity",
description="Atmospheric turbidity (1.0 to 10.0). Lower values = clearer sky",
min=1.0,
max=10.0,
default=3.0,
)
use_active_camera: BoolProperty(
name="Use Active Camera", description="Use the active camera in the scene", default=True
)
@@ -424,8 +608,90 @@ class RadianceExporterProperties(PropertyGroup):
poll=lambda self, object: object.type == "CAMERA",
)
ies_lights: CollectionProperty(
type=IESLight,
name="IES Lights",
description="Collection of IES light fixtures mapped to scene objects",
)
active_ies_light_index: IntProperty(
name="Active IES Light Index",
description="Index of the active IES light in the collection",
default=-1,
)
# False color analysis properties
use_false_color: BoolProperty(
name="Generate False Color Image",
description="Generate a false color HDR image for illuminance analysis",
default=False,
)
false_color_label: EnumProperty(
name="Legend Label Unit",
description="Unit for the false color legend",
items=[
("fc", "Foot-Candles", "US lighting standard unit"),
("lux", "Lux", "International lighting standard unit"),
("cd/m2", "Candela per m²", "Luminance unit"),
],
default="fc",
)
false_color_scale: FloatProperty(
name="Scale Factor",
description="Maximum scale value for the false color legend",
min=0.1,
max=100000.0,
default=3.0,
)
false_color_steps: IntProperty(
name="Legend Steps",
description="Number of divisions on the legend. Contour increment = Scale / Steps. "
"E.g. Scale=20, Steps=10 → contours at 2, 4, 6, 8...",
min=2,
max=50,
default=10,
)
false_color_contour_lines: BoolProperty(
name="Enable Contour Lines",
description="Add contour lines to the false color image",
default=True,
)
false_color_contour_mode: EnumProperty(
name="Contour Mode",
description="Contour line display mode",
items=[
(
"WITH_BG",
"Contour Lines with Background",
"Show contour lines overlaid on the colored false color background",
),
("WITHOUT_BG", "Contour Lines Only", "Show only contour lines without the false color background"),
],
default="WITH_BG",
)
false_color_multiplier: FloatProperty(
name="Multiplier",
description="Conversion multiplier (179.0 for lux, 16.6295 for foot-candles)",
min=0.1,
max=1000.0,
default=16.629505759940542,
)
false_color_output_name: StringProperty(
name="False Color Output Name",
description="Name of the false color output file (without extension)",
default="false_color",
)
if TYPE_CHECKING:
is_exporting: bool
is_preparing: bool
is_rendering: bool
category: str
subcategory: str
materials: bpy.types.bpy_prop_collection_idprop[RadianceMaterial]
@@ -433,8 +699,10 @@ class RadianceExporterProperties(PropertyGroup):
should_load_from_memory: bool
radiance_resolution_x: int
radiance_resolution_y: int
radiance_bin_dir: str
output_dir: str
ifc_file: str
ambient_bounces: int
radiance_quality: Literal["LOW", "MEDIUM", "HIGH"]
radiance_detail: Literal["LOW", "MEDIUM", "HIGH"]
radiance_variability: Literal["LOW", "MEDIUM", "HIGH"]
@@ -442,8 +710,22 @@ class RadianceExporterProperties(PropertyGroup):
output_file_format: Literal["HDR"]
use_hdr: bool
choose_hdr_image: Literal["Noon"]
use_sun: bool
sky_condition: Literal["SUNNY_WITH_SUN", "SUNNY_WITHOUT_SUN", "CLOUDY"]
ground_reflectance: float
turbidity: float
use_active_camera: bool
selected_camera: Union[bpy.types.Object, None]
ies_lights: bpy.types.bpy_prop_collection_idprop[IESLight]
active_ies_light_index: int
use_false_color: bool
false_color_label: Literal["fc", "lux", "cd/m2"]
false_color_scale: float
false_color_steps: int
false_color_contour_lines: bool
false_color_contour_mode: Literal["WITH_BG", "WITHOUT_BG"]
false_color_multiplier: float
false_color_output_name: str
class BIMSolarProperties(PropertyGroup):
@@ -470,11 +752,12 @@ class BIMSolarProperties(PropertyGroup):
)
timezone: StringProperty(name="Timezone", default="Etc/GMT")
true_north: FloatProperty(name="True North", min=-pi, max=pi, subtype="ANGLE", update=update_sun_path)
year: IntProperty(name="Year", min=1, max=9999, default=now.year, update=update_sun_path)
month: IntProperty(name="Month", min=1, max=12, default=now.month, update=update_sun_path)
day: IntProperty(name="Date", min=1, max=31, default=now.day, update=update_sun_path)
hour: IntProperty(name="Hour", min=0, max=23, default=now.hour, update=update_sun_path)
minute: IntProperty(name="Minute", min=0, max=59, default=now.minute, update=update_sun_path)
# Defaults are static; use the "Now" button (LightSetTimeToNow) to set current time.
year: IntProperty(name="Year", min=1, max=9999, default=2025, update=update_day)
month: IntProperty(name="Month", min=1, max=12, default=1, update=update_day)
day: IntProperty(name="Date", min=1, max=31, default=1, update=update_day)
hour: IntProperty(name="Hour", min=0, max=23, default=12, update=update_sun_path)
minute: IntProperty(name="Minute", min=0, max=59, default=0, update=update_sun_path)
sun_position: FloatVectorProperty(name="Sun Position", subtype="XYZ", default=(0, 0, 0))
sun_path_origin: FloatVectorProperty(name="Sun Path Origin", subtype="XYZ", default=(0, 0, 0))
sun_path_size: FloatProperty(name="Sun Path Size", min=0.1, default=50, update=update_sun_path)
@@ -0,0 +1,328 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import multiprocessing
import os
import subprocess
import threading
import time
from pathlib import Path
from typing import Union
import bpy
import pyradiance as pr
import bonsai.bim.module.light.shared as shared
import bonsai.tool as tool
# pyradiance's bundled Radiance binaries
_PYRAD_BIN = Path(pr.__file__).parent / "bin"
class RadianceRender(bpy.types.Operator):
"""Radiance Rendering (runs in background thread)"""
bl_idname = "render_scene.radiance"
bl_label = "Render"
bl_description = "Renders the scene using Radiance"
_timer = None
_thread: Union[threading.Thread, None] = None
_result_image: Union[bytes, None] = None
_error: Union[str, None] = None
_start_time: float = 0.0
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
if props.is_rendering:
cls.poll_message_set("A render is already in progress.")
return False
if shared.scene is None:
cls.poll_message_set("Radiance scene not prepared. Please run 'Prepare Scene' (Step 2) first.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
quality = props.radiance_quality.upper()
detail = props.radiance_detail.upper()
variability = props.radiance_variability.upper()
ambient_bounces = props.ambient_bounces
output_dir = props.output_dir
props.is_rendering = True
self._start_time = time.time()
self._result_image = None
self._error = None
self._thread = threading.Thread(
target=self._render_worker,
args=(shared.scene, output_dir, resolution_x, resolution_y, quality, detail, variability, ambient_bounces),
daemon=True,
)
self._thread.start()
wm = context.window_manager
self._timer = wm.event_timer_add(0.5, window=context.window)
wm.modal_handler_add(self)
self.report({"INFO"}, "Radiance render started in background...")
context.window.cursor_set("WAIT")
return {"RUNNING_MODAL"}
def _render_worker(self, render_scene, output_dir, res_x, res_y, quality, detail, variability, ambient_bounces):
"""Runs in a background thread — no Blender API calls allowed here."""
cwd_saved = os.getcwd()
try:
os.chdir(output_dir)
self._result_image = pr.render(
render_scene,
ambbounce=ambient_bounces,
resolution=(res_x, res_y),
quality=quality,
detail=detail,
variability=variability,
nproc=multiprocessing.cpu_count(),
)
except Exception as e:
self._error = str(e)
finally:
os.chdir(cwd_saved)
def modal(self, context, event):
if event.type == "TIMER":
if self._thread is not None and self._thread.is_alive():
return {"RUNNING_MODAL"}
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_rendering = False
context.window.cursor_set("DEFAULT")
if self._error:
self.report({"ERROR"}, f"Radiance render failed: {self._error}")
return {"CANCELLED"}
elapsed = time.time() - self._start_time
print(f"Render completed in {elapsed:.2f} seconds")
output_dir = props.output_dir
output_file_name = props.output_file_name
output_file_format = props.output_file_format
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
print(f"Saving HDR output to: {output_hdr_path}")
with open(output_hdr_path, "wb") as wtr:
wtr.write(self._result_image)
if output_file_format == "HDR_TIFF":
print("Applying tone mapping...")
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
print(f"Saving TIFF output to: {tiff_path}")
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
print("Radiance rendering process completed successfully.")
self.report({"INFO"}, f"Radiance rendering completed. HDR Output: {output_hdr_path}")
if output_file_format == "HDR_TIFF":
self.report({"INFO"}, f"TIFF Output: {tiff_path}")
for area in context.screen.areas:
area.tag_redraw()
return {"FINISHED"}
elif event.type == "ESC":
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_rendering = False
context.window.cursor_set("DEFAULT")
self.report({"WARNING"}, "Render cancelled by user. Background process may still be running.")
return {"CANCELLED"}
return {"PASS_THROUGH"}
def _cleanup_timer(self, context):
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
class FalseColorRadiance(bpy.types.Operator):
"""Generate false color HDR image for illuminance analysis"""
bl_idname = "render_scene.false_color_radiance"
bl_label = "Generate False Color Image"
bl_description = "Generate a false color HDR image for illuminance analysis"
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
output_file_name = props.output_file_name
hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
if not os.path.exists(hdr_path):
self.report(
{"ERROR"},
f"HDR file not found at: {hdr_path}. Please run 'Radiance Render' first.",
)
return {"CANCELLED"}
fc_scale = (
str(int(props.false_color_scale))
if props.false_color_scale == int(props.false_color_scale)
else str(props.false_color_scale)
)
# Multiplier converts Radiance raw values to display units
# fc (foot-candles) = 16.629..., lux & cd/m2 = 179.0
multiplier = 179.0 if props.false_color_label in ("lux", "cd/m2") else 16.629505759940542
print(
f"False color parameters: label={props.false_color_label}, scale={fc_scale}, "
f"steps={props.false_color_steps}, multiplier={multiplier}, "
f"contour={props.false_color_contour_lines}"
)
try:
fc_output_name = props.false_color_output_name
fc_hdr_path = os.path.join(output_dir, f"{fc_output_name}.hdr")
# Find falsecolor binary from user-specified Radiance bin directory
radiance_bin_dir = os.path.normpath(props.radiance_bin_dir) if props.radiance_bin_dir else ""
if radiance_bin_dir:
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor.exe")
if not os.path.exists(falsecolor_bin):
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor")
if not os.path.exists(falsecolor_bin):
self.report({"ERROR"}, f"falsecolor not found in: {radiance_bin_dir}")
return {"CANCELLED"}
else:
import shutil
falsecolor_bin = shutil.which("falsecolor") or shutil.which("falsecolor.exe")
if not falsecolor_bin:
self.report(
{"ERROR"}, "Set the Radiance Bin path in False Color settings, or add Radiance to system PATH."
)
return {"CANCELLED"}
radiance_bin_dir = os.path.dirname(falsecolor_bin)
radiance_lib = os.path.join(os.path.dirname(radiance_bin_dir), "lib")
print(f"Using falsecolor: {falsecolor_bin}")
print(f"Radiance bin: {radiance_bin_dir}, lib: {radiance_lib}")
cmd = [falsecolor_bin]
cmd.extend(["-m", str(multiplier)])
cmd.extend(["-s", fc_scale])
cmd.extend(["-n", str(props.false_color_steps)])
cmd.extend(["-l", props.false_color_label])
if props.false_color_contour_lines:
cmd.append("-cl")
if props.false_color_contour_mode == "WITH_BG":
cmd.extend(["-ip", hdr_path])
else:
cmd.extend(["-i", hdr_path])
else:
cmd.extend(["-ip", hdr_path])
# Setup environment so falsecolor can find pcomb, psign, pcompos etc.
env = os.environ.copy()
if os.path.exists(radiance_bin_dir):
env["PATH"] = radiance_bin_dir + os.pathsep + env.get("PATH", "")
if os.path.exists(radiance_lib):
env["RAYPATH"] = "." + os.pathsep + radiance_lib
print(f"Running: {' '.join(cmd)}")
# Write output to file via redirection to avoid Windows stdout binary corruption
cmd_str = subprocess.list2cmdline(cmd) + f' > "{fc_hdr_path}"'
result = subprocess.run(cmd_str, shell=True, stderr=subprocess.PIPE, env=env, cwd=output_dir)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
self.report({"ERROR"}, f"falsecolor failed: {error_msg}")
return {"CANCELLED"}
fc_size = os.path.getsize(fc_hdr_path) if os.path.exists(fc_hdr_path) else 0
print(f"False color HDR generated: {fc_hdr_path} ({fc_size} bytes)")
self.report({"INFO"}, f"False color image generated: {fc_hdr_path}")
# Generate TIFF version
try:
pcond_fc_image = pr.pcond(hdr=fc_hdr_path, human=True)
fc_tiff_path = os.path.join(output_dir, f"{fc_output_name}.tiff")
pr.ra_tiff(inp=pcond_fc_image, out=fc_tiff_path, lzw=True)
print(f"False color TIFF generated: {fc_tiff_path}")
self.report({"INFO"}, f"False color TIFF also generated: {fc_tiff_path}")
except Exception as e:
self.report({"WARNING"}, f"TIFF generation failed: {str(e)}")
return {"FINISHED"}
except subprocess.CalledProcessError as e:
# Clean up incomplete HDR file on failure
if os.path.exists(fc_hdr_path):
os.remove(fc_hdr_path)
error_msg = f"falsecolor failed: {e.stderr.decode() if e.stderr else str(e)}"
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
except FileNotFoundError:
self.report({"ERROR"}, "falsecolor not found. Please install Radiance and add it to PATH.")
return {"CANCELLED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to generate false color image: {str(e)}")
import traceback
traceback.print_exc()
return {"CANCELLED"}
class RADIANCE_OT_select_camera(bpy.types.Operator):
bl_idname = "radiance.select_camera"
bl_label = "Select Camera"
bl_description = "Select a camera from the viewport"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == "CAMERA"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.selected_camera = context.object
props.use_active_camera = False
return {"FINISHED"}
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
@@ -15,5 +15,19 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared module-level state for the light/radiance pipeline.
These globals are populated by ExportOBJ, consumed by PrepareRadianceScene,
and read by RadianceRender. They must live in a shared module so that
all operator files can access the same state.
"""
# Collected IFC material names from the most recent export
ifc_materials: list[str] = []
# The prepared pyradiance Scene object (set by PrepareRadianceScene, read by RadianceRender)
scene = None
# Info about exported linked models: list of (obj_path, mtl_path, link_matrix_4x4)
linked_model_exports: list[tuple[str, str, list[list[float]]]] = []
+148
View File
@@ -0,0 +1,148 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import webbrowser
from datetime import datetime
from math import radians
from typing import TYPE_CHECKING
import bpy
import ifcopenshell.util.geolocation
import requests
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
class ImportTrueNorth(bpy.types.Operator):
bl_idname = "bim.import_true_north"
bl_label = "Import True North"
bl_description = "Imports the True North from your IFC geometric context"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not SolarData.is_loaded:
SolarData.load()
return SolarData.data["true_north"] is not None
def execute(self, context):
props = tool.Blender.get_solar_props()
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if not context.TrueNorth:
continue
value = context.TrueNorth.DirectionRatios
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
return {"FINISHED"}
class ImportLatLong(bpy.types.Operator):
bl_idname = "bim.import_lat_long"
bl_label = "Import Latitude / Longitude"
bl_description = "Imports the latitude / longitude from an IfcSite"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
site = tool.Ifc.get().by_id(int(props.sites))
if site.RefLatitude and site.RefLongitude:
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
return {"FINISHED"}
class MoveSunPathTo3DCursor(bpy.types.Operator):
bl_idname = "bim.move_sun_path_to_3d_cursor"
bl_label = "Move Sun Path To 3D Cursor"
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
assert context.scene
props.sun_path_origin = context.scene.cursor.location
tool.Blender.update_viewport()
return {"FINISHED"}
class ViewFromSun(bpy.types.Operator):
bl_idname = "bim.view_from_sun"
bl_label = "View From Sun"
bl_description = "Views your model as if you were looking from the perspective of the sun"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if not (camera := bpy.data.objects.get("SunPathCamera")):
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
assert isinstance(camera.data, bpy.types.Camera)
assert context.scene
camera.data.type = "ORTHO"
camera.data.ortho_scale = 100 # The default of 6m is too small
context.scene.collection.objects.link(camera)
tool.Blender.activate_camera(camera)
props = tool.Blender.get_solar_props()
props.hour = props.hour # Just to refresh camera position
return {"FINISHED"}
class LightPickCoordinates(bpy.types.Operator):
bl_idname = "bim.light_pick_coordinates"
bl_label = "Pick Coordinates"
bl_description = (
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
)
bl_options = {"REGISTER", "UNDO"}
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
use_current_location: bool
def invoke(self, context, event):
if event.alt:
self.use_current_location = True
return self.execute(context)
def execute(self, context):
props = tool.Blender.get_solar_props()
if not self.use_current_location:
zoom = 13.5
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
webbrowser.open(url)
return {"FINISHED"}
response = requests.get("http://ip-api.com/json/")
data = response.json()
props.latitude = data["lat"]
props.longitude = data["lon"]
return {"FINISHED"}
class LightSetTimeToNow(bpy.types.Operator):
bl_idname = "bim.light_set_time_to_now"
bl_label = "Now"
bl_description = "Set time to current local time."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
props.set_from_datetime(datetime.now())
return {"FINISHED"}
+235 -43
View File
@@ -22,52 +22,97 @@ from typing import TYPE_CHECKING
import bpy
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from bonsai.bim.module.light.data import SolarData
# ---------------------------------------------------------------------------
# Root panel (replaces the old "Radiance Exporter" nested panel)
# ---------------------------------------------------------------------------
class BIM_PT_radiance_exporter(bpy.types.Panel):
"""Creates a Panel in the render properties window"""
bl_label = "Radiance Exporter"
bl_idname = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_lighting"
bl_options = {"HIDE_HEADER"}
def draw(self, context):
pass
# ---------------------------------------------------------------------------
# 1. Scene Setup
# ---------------------------------------------------------------------------
class BIM_PT_radiance_scene_setup(bpy.types.Panel):
bl_label = "Scene Setup"
bl_idname = "BIM_PT_radiance_scene_setup"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
assert self.layout
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
if tool.Ifc.get():
row = self.layout.row()
row.prop(props, "should_load_from_memory")
if not tool.Ifc.get() or not props.should_load_from_memory:
row = self.layout.row(align=True)
row.prop(props, "ifc_file")
row = layout.row()
row.prop(props, "output_dir")
layout.separator()
row = layout.row()
layout.label(text="Info: Unmapped materials default to white")
row.prop(props, "use_active_camera")
if not props.use_active_camera:
row = layout.row()
row.prop(props, "selected_camera")
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
row = layout.row(align=True)
row.label(text="Resolution")
row.prop(props, "radiance_resolution_x", text="X")
row.prop(props, "radiance_resolution_y", text="Y")
# ---------------------------------------------------------------------------
# 2. Materials
# ---------------------------------------------------------------------------
class BIM_PT_radiance_materials(bpy.types.Panel):
bl_label = "Materials"
bl_idname = "BIM_PT_radiance_materials"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
layout.label(text="Unmapped materials default to white", icon="INFO")
row = layout.row()
row.template_list("MATERIAL_UL_radiance_materials", "", props, "materials", props, "active_material_index")
row.operator("radiance.open_spectraldb", text="", icon="WORLD") # Globe icon
row.operator("radiance.open_spectraldb", text="", icon="WORLD")
if len(props.materials) > 0:
col = layout.column(align=True)
col.prop(props, "category")
prop_with_search(col, props, "category")
if props.category:
col.prop(props, "subcategory")
prop_with_search(col, props, "subcategory")
if props.active_material_index >= 0 and props.active_material_index < len(props.materials):
if 0 <= props.active_material_index < len(props.materials):
active_material = props.materials[props.active_material_index]
if active_material.category and active_material.subcategory:
layout.label(
text=f"Mapped: {active_material.name} to {active_material.category} - {active_material.subcategory}"
text=f"Mapped: {active_material.name} -> {active_material.category} - {active_material.subcategory}"
)
else:
layout.label(text=f"Select category and subcategory for: {active_material.name}")
@@ -78,28 +123,95 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.operator("bim.refresh_ifc_materials", text="Refresh IFC Materials")
layout.separator()
row = layout.row()
layout.label(text="Step 1: Export geometry for simulation")
row = layout.row()
row.operator("export_scene.radiance", text="Export Geometry for Simulation")
# ---------------------------------------------------------------------------
# 3. Lighting (Environment + IES)
# ---------------------------------------------------------------------------
layout.separator()
class BIM_PT_radiance_lighting(bpy.types.Panel):
bl_label = "Lighting"
bl_idname = "BIM_PT_radiance_lighting"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
# Environment
box = layout.box()
box.label(text="Camera Settings")
box.label(text="Environment", icon="WORLD")
row = box.row()
row.prop(props, "use_active_camera")
if not props.use_active_camera:
row.prop(props, "use_hdr")
row = box.row()
row.prop(props, "use_sun")
if props.use_sun:
box.prop(props, "sky_condition")
row = box.row()
row.prop(props, "selected_camera")
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
row.prop(props, "ground_reflectance")
row = box.row()
row.prop(props, "turbidity")
row = box.row(align=True)
row.label(text="Resolution")
row.prop(props, "radiance_resolution_x", text="X")
row.prop(props, "radiance_resolution_y", text="Y")
layout.separator()
# IES Light Fixtures
box = layout.box()
box.label(text="IES Light Fixtures", icon="LIGHT_POINT")
row = box.row()
row.template_list("MATERIAL_UL_ies_lights", "", props, "ies_lights", props, "active_ies_light_index")
col = row.column(align=True)
col.operator("radiance.add_ies_light", text="", icon="ADD")
if len(props.ies_lights) > 0 and 0 <= props.active_ies_light_index < len(props.ies_lights):
active_light = props.ies_lights[props.active_ies_light_index]
col = box.column(align=True)
row = col.row()
row.prop(active_light, "use_collection", text="Target Collection", icon="OUTLINER_COLLECTION")
row = col.row()
if active_light.use_collection:
row.prop(active_light, "target_collection", text="Collection")
if active_light.target_collection:
empties = [o for o in active_light.target_collection.all_objects if o.type == "EMPTY"]
row = col.row()
row.label(text=f"{len(empties)} empty object(s) in collection", icon="INFO")
else:
row.prop(active_light, "target_object", text="Object")
row = col.row()
row.label(text="Rotation Z")
row.prop(active_light, "rotation_z", text="")
row = col.row()
row.prop(active_light, "lamp_color")
split = col.split(factor=0.5)
split.prop(active_light, "multiply_factor")
split.prop(active_light, "radius")
# ---------------------------------------------------------------------------
# 4. Render Settings
# ---------------------------------------------------------------------------
class BIM_PT_radiance_render_settings(bpy.types.Panel):
bl_label = "Render Settings"
bl_idname = "BIM_PT_radiance_render_settings"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
row = layout.row()
row.prop(props, "radiance_quality")
@@ -110,6 +222,9 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.prop(props, "radiance_variability")
row = layout.row()
row.prop(props, "ambient_bounces")
layout.separator()
row = layout.row()
@@ -117,20 +232,94 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.prop(props, "output_file_format")
# ---------------------------------------------------------------------------
# 5. Pipeline (Steps 1-4 + Cleanup)
# ---------------------------------------------------------------------------
class BIM_PT_radiance_pipeline(bpy.types.Panel):
bl_label = "Pipeline"
bl_idname = "BIM_PT_radiance_pipeline"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
# Step 1
box = layout.box()
row = box.row()
row.label(text="Step 1: Export Geometry")
row = box.row()
if props.is_exporting:
row.label(text="Exporting...", icon="SORTTIME")
else:
row.operator("export_scene.radiance", text="Export Geometry")
# Step 2
box = layout.box()
row = box.row()
row.label(text="Step 2: Prepare Scene")
row = box.row()
if props.is_preparing:
row.label(text="Preparing scene...", icon="SORTTIME")
else:
row.operator("scene.prepare_radiance", text="Prepare Scene")
# Step 3
box = layout.box()
row = box.row()
row.label(text="Step 3: Render")
row = box.row()
if props.is_rendering:
row.label(text="Rendering...", icon="RENDER_STILL")
else:
row.operator("render_scene.radiance", text="Radiance Render")
# Step 4: False Color
box = layout.box()
row = box.row()
row.label(text="Step 4: False Color Analysis")
row = box.row()
row.prop(props, "radiance_bin_dir")
row = box.row()
row.prop(props, "false_color_label")
split = box.split(factor=0.5)
split.prop(props, "false_color_scale")
split.prop(props, "false_color_steps")
row = box.row()
row.label(text="Output Name")
row.prop(props, "false_color_output_name", text="")
row = box.row()
row.prop(props, "false_color_contour_lines")
if props.false_color_contour_lines:
row = box.row()
row.prop(props, "false_color_contour_mode")
row = box.row()
row.operator("render_scene.false_color_radiance", text="Generate False Color Image")
layout.separator()
# Cleanup
row = layout.row()
row.prop(props, "use_hdr")
row.operator("radiance.cleanup_files", text="Cleanup Generated Files", icon="TRASH")
if props.use_hdr:
row = layout.row()
row.prop(props, "choose_hdr_image")
row = layout.row()
layout.label(text="Step 2: Run the simulation")
row = layout.row()
row.operator("render_scene.radiance", text="Radiance Render")
row.enabled = not props.is_exporting
# ---------------------------------------------------------------------------
# Solar Panel (unchanged)
# ---------------------------------------------------------------------------
class BIM_PT_solar(bpy.types.Panel):
@@ -248,7 +437,10 @@ class BIM_PT_solar(bpy.types.Panel):
row = self.layout.row()
sun_props = tool.Blender.get_sun_props()
assert sun_props
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
if sun_props.sun_object is not None and sun_props.sun_object.data is not None:
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
else:
row.label(text="Sun object not found. Toggle shadow mode to recreate.", icon="ERROR")
row = self.layout.row(align=True)
row.operator("bim.view_from_sun", icon="LIGHT_HEMI")
@@ -630,23 +630,13 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set)
if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
if "CardinalPoint" in attributes and attributes["CardinalPoint"] is not None:
if "CardinalPoint" in attributes:
attributes["CardinalPoint"] = int(attributes["CardinalPoint"])
ifcopenshell.api.material.edit_profile_usage(
self.file,
usage=material_set_usage,
attributes=attributes,
)
for obj in objects:
obj_element = tool.Ifc.get_entity(obj)
if not obj_element:
continue
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)
if obj_material_usage and obj_material_usage.is_a("IfcMaterialProfileSetUsage"):
obj_material_usage.CardinalPoint = material_set_usage.CardinalPoint
obj_material_usage.ReferenceExtent = material_set_usage.ReferenceExtent
model_profile.DumbProfileRecalculator().recalculate(objects)
bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name)
+10 -46
View File
@@ -15,15 +15,11 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from typing import NamedTuple
import bpy
import bonsai.tool as tool
from . import (
array,
covering,
@@ -61,8 +57,6 @@ classes = (
array.Input3DCursorXArray,
array.Input3DCursorYArray,
array.Input3DCursorZArray,
array.EnableEditingParametric,
array.AddArrayFromFeatureEdit,
product.AddDefaultType,
product.AddEmptyType,
product.AddOccurrence,
@@ -76,43 +70,19 @@ classes = (
workspace.BIM_MT_add_representation_item,
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
wall.ChangeExtrusionDepth,
wall.ChangeExtrusionXAngle,
wall.ChangeLayerLength,
wall.CycleWallOffset,
wall.DrawPolylineWall,
wall.EnableEditingWall,
wall.ExtendWallHeightToCursor,
wall.ExtendWallsToUnderside,
wall.RegenerateWallToUnderside,
wall.ExtendWallsToWall,
wall.ExtendWallsToPolylinePoint,
wall.ExtendWallToCursor,
wall.FinishEditingWall,
wall.FlipWall,
wall.GizmoWallAddOpening,
wall.GizmoWallEdition,
wall.GizmoWallExtendVertically,
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallJoinIntersection,
wall.GizmoWallUnjoinSingle,
wall.JoinWallsIntersection,
wall.MergeWall,
wall.OffsetWalls,
wall.RecalculateWall,
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.ToggleWallOpenings,
wall.UnjoinWallPathConnection,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
wall.CancelWallFilletPreview,
wall.EnableWallFilletPreviewFromCorner,
wall.CreateWallFillet,
opening.AddBoolean,
opening.CloneOpening,
opening.EditOpenings,
@@ -170,14 +140,10 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMWallProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
ui.BIM_PT_sverchok,
ui.BIM_PT_window,
ui.BIM_PT_door,
@@ -298,14 +264,15 @@ def register():
bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties)
bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties)
bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties)
bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties)
bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties)
# Per-parametric-type ``BIM<Name>Properties`` PointerProperties — driven by
# ``tool.Parametric.EDIT_TYPES``; adding a registry entry is the single touchpoint.
tool.Parametric.register_object_properties(prop)
bpy.types.Object.BIMWindowProperties = bpy.props.PointerProperty(type=prop.BIMWindowProperties)
bpy.types.Object.BIMDoorProperties = bpy.props.PointerProperty(type=prop.BIMDoorProperties)
bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties)
bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties)
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
type=prop.BIMExternalParametricGeometryProperties
)
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
bpy.app.handlers.load_post.append(handler.load_post)
@@ -314,12 +281,6 @@ def register():
def unregister():
# DecorationsHandler is installed lazily by bim.show_openings; tear it down
# (along with its persistent depsgraph / undo / redo / load cache handlers)
# before the rest of unregister so those handlers can't fire against
# half-unloaded module state.
opening.DecorationsHandler.uninstall()
if not bpy.app.background:
for tool_data in reversed(tools):
bpy.utils.unregister_tool(tool_data.tool)
@@ -327,10 +288,13 @@ def unregister():
del bpy.types.Scene.BIMModelProperties
del bpy.types.Scene.BIMPolylineProperties
del bpy.types.Object.BIMArrayProperties
del bpy.types.Object.BIMStairProperties
del bpy.types.Object.BIMSverchokProperties
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMWindowProperties
del bpy.types.Object.BIMDoorProperties
del bpy.types.Object.BIMRailingProperties
del bpy.types.Object.BIMRoofProperties
del bpy.types.Object.BIMExternalParametricGeometryProperties
del bpy.types.Scene.BIMPreviewProperties
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
-126
View File
@@ -379,129 +379,3 @@ class Input3DCursorZArray(bpy.types.Operator):
else:
props.z = cursor.location.z - obj.location.z
return {"FINISHED"}
class EnableEditingParametric(bpy.types.Operator):
"""Pen-icon dispatcher: fires the gizmo group's per-feature edit operator.
Bound to every parametric gizmo group's pen icon. The gizmo group's own
``enable_editing_operator`` (``bim.enable_editing_door``, ``_wall``, )
is passed as ``feature_enable_op`` at setup time and invoked here. The
indirection lets one gizmo class serve all features without per-feature
subclasses."""
bl_idname = "bim.enable_editing_parametric"
bl_label = "Enable Editing"
bl_description = "Edit this object's parameters"
bl_options = {"REGISTER", "UNDO"}
feature_enable_op: bpy.props.StringProperty(
default="",
description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').",
)
def execute(self, context):
# Malformed ``feature_enable_op`` (missing dot) would otherwise crash
# the unpack with ValueError; treat the same as the empty-string case.
parts = self.feature_enable_op.split(".", 1)
if len(parts) != 2:
return {"CANCELLED"}
domain, opname = parts
return getattr(getattr(bpy.ops, domain), opname)("INVOKE_DEFAULT")
class AddArrayFromFeatureEdit(bpy.types.Operator, tool.Ifc.Operator):
"""Commit any in-progress feature edit and add an array with
gizmo-friendly defaults (count=2, offset = bbox extent along the axis).
Modifier-aware: plain click X, Shift Y, Ctrl Z. Callers can pass
``axis="X"`` via EXEC_DEFAULT to bypass the modifier read.
All three chained operators (feature finish + add_array + enable_editing)
run inside one transaction for a single undo step."""
bl_idname = "bim.add_array_from_feature_edit"
bl_label = "Add Array"
bl_description = (
"Click: add an array along X.\n" "Shift+Click: add an array along Y.\n" "Ctrl+Click: add an array along Z"
)
bl_options = {"REGISTER", "UNDO"}
axis: bpy.props.EnumProperty(
name="Offset Axis",
items=[
("X", "X", "Offset along the object's X axis (bbox X extent)"),
("Y", "Y", "Offset along the object's Y axis (bbox Y extent)"),
("Z", "Z", "Offset along the object's Z axis (bbox Z extent)"),
],
default="X",
)
# Minimum offset to use when the object's bbox extent is tiny — prevents
# the second instance from visually overlapping the parent on small
# annotations / openings (0.3m ≈ a clearly-separated next-instance distance).
MIN_DEFAULT_OFFSET = 0.3
def invoke(self, context, event):
# Modifier-aware axis pick: X by default, Shift → Y, Ctrl → Z.
if event.shift:
self.axis = "Y"
elif event.ctrl:
self.axis = "Z"
else:
self.axis = "X"
return self.execute(context)
def _execute(self, context):
obj = context.active_object
if obj is None:
return {"CANCELLED"}
# Commit any in-progress parametric edit lifecycle on this object first — the
# user expects "Add Array" to also finalise whatever they were editing
# so they don't lose their draft changes.
editing = tool.Parametric.is_object_editing(obj, skip_name="array")
if editing is not None:
finish_op_name = editing.finish_op.removeprefix("bim.")
getattr(bpy.ops.bim, finish_op_name)("INVOKE_DEFAULT")
# Bounding-box derived offset along the chosen axis, converted from
# Blender SI (meters) to IFC project units (which is what
# ``BBIM_Array.Data`` stores; the regenerator multiplies by
# unit_scale on the way out).
axis_idx = "XYZ".index(self.axis)
if obj.bound_box:
bbox_extent_si = max(c[axis_idx] for c in obj.bound_box) - min(c[axis_idx] for c in obj.bound_box)
else:
bbox_extent_si = 1.0
bbox_extent_si = max(bbox_extent_si, self.MIN_DEFAULT_OFFSET)
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
offset_project = bbox_extent_si / si_conversion if si_conversion else bbox_extent_si
add_kwargs = {"count": 2, "x": 0.0, "y": 0.0, "z": 0.0}
add_kwargs[self.axis.lower()] = offset_project
result = bpy.ops.bim.add_array(**add_kwargs)
if result != {"FINISHED"}:
return result
# Restore selection to just the parent. ``regenerate_array`` calls
# ``tool.Geometry.duplicate_ifc_objects`` which leaves the newly-created
# child selected alongside the parent. The edit-lifecycle gizmos poll on a
# single-selected parent, so with both selected the gizmos wouldn't
# surface and "ARRAY → enter edit" would feel broken.
tool.Blender.select_and_activate_single_object(context, active_object=obj)
# Chain straight into array edit for the newly-added layer (always the
# last entry in the pset's Data list, by AddArray's append semantics).
# The user's expectation after clicking ARRAY is "I want to tweak this
# array now" — entering edit mode immediately collapses the 2-click
# discover-then-edit flow into one.
element = tool.Ifc.get_entity(obj)
if element is None:
return {"FINISHED"}
data_text = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Data")
if not data_text:
return {"FINISHED"}
try:
layers = json.loads(data_text)
except (ValueError, TypeError):
return {"FINISHED"}
if not layers:
return {"FINISHED"}
bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=len(layers) - 1)
return {"FINISHED"}
+1 -149
View File
@@ -108,7 +108,7 @@ class ProfileDecorator:
obj = context.active_object
if obj is None or obj.mode != "EDIT":
if obj.mode != "EDIT":
if exit_edit_mode_callback:
ProfileDecorator.uninstall()
exit_edit_mode_callback()
@@ -2029,151 +2029,3 @@ class BoundingBoxDecorator:
else:
co1.y += y_overlap / 2 + min_spacing
co2.y -= y_overlap / 2 + min_spacing
def _stroke_lines_alpha(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
line_width: float,
line_alpha: float,
) -> None:
"""Render ``segments`` (a list of ``(start, end)`` tuples) as one
anti-aliased LINES batch in world space. Early-returns when
``context.region`` is unavailable (e.g. when called from a
``_RestrictContext``)."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(tuple(start))
verts.append(tuple(end))
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, line_alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the wall-fillet flow.
Polls on ``scene.BIMPreviewProperties.wall_fillet.is_active`` and renders
the leg projections + arc + radial construction lines returned by
``tool.Wall.compute_wall_fillet_geometry``. The two leg lines show how
each wall will be shortened to its tangent point; the arc approximates
the rounded corner; the two construction lines (arc center to each
tangent point) visually pin the radius.
Installed once per Blender session from ``bim/handler.py:load_post``
and uninstalled in ``bim/module/model/__init__.py:unregister``."""
LINE_WIDTH_LEG = 1.5
LINE_WIDTH_ARC = 2.5
LINE_WIDTH_CONSTRUCTION = 1.0
LINE_ALPHA = 0.7
CONSTRUCTION_ALPHA = 0.4
def draw(self, context: bpy.types.Context) -> None:
scene = context.scene
preview_props = getattr(scene, "BIMPreviewProperties", None)
props = preview_props.wall_fillet if preview_props is not None else None
if props is None or not props.is_active:
return
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
try:
wall_a = ifc_file.by_id(props.wall_a_id)
wall_b = ifc_file.by_id(props.wall_b_id)
except Exception:
return
wall_a_obj = tool.Ifc.get_object(wall_a) if wall_a else None
wall_b_obj = tool.Ifc.get_object(wall_b) if wall_b else None
if wall_a_obj is None or wall_b_obj is None:
return
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius)
if geom is None:
return
prefs = tool.Blender.get_addon_preferences()
warning_color = tuple(prefs.decorator_color_error[:3])
if not geom["valid"]:
# Degenerate geometry paints red: invalid_radius shows legs+arc
# past the wall ends; invalid_axes shows the parallel/collinear
# axes.
if geom.get("invalid_radius"):
tangent_a = geom.get("tangent_a")
tangent_b = geom.get("tangent_b")
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
if tangent_a is not None and tangent_b is not None and ref_a is not None and ref_b is not None:
far_a = self._far_endpoint(ref_a, geom["intersection"])
far_b = self._far_endpoint(ref_b, geom["intersection"])
legs = [
(tuple(far_a), tuple(tangent_a)),
(tuple(far_b), tuple(tangent_b)),
]
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
arc = geom.get("arc") or []
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
elif geom.get("invalid_axes"):
axes = geom["invalid_axes"]
segments = [(tuple(a), tuple(b)) for a, b in axes]
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
return
leg_color = tuple(prefs.decorations_colour[:3])
arc_color = tuple(prefs.decorator_color_selected[:3])
# Resolved against the IFC reference line, not mesh bounds, so trimmed
# walls and openings don't shift the leg endpoints.
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
if ref_a is not None and ref_b is not None and geom["intersection"] is not None:
far_a = self._far_endpoint(ref_a, geom["intersection"])
far_b = self._far_endpoint(ref_b, geom["intersection"])
legs = [
(tuple(far_a), tuple(geom["tangent_a"])),
(tuple(far_b), tuple(geom["tangent_b"])),
]
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
arc = geom["arc"]
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
# Dim construction lines from arc_center to each tangent point so
# the radius reads as concrete during drag.
arc_center = geom.get("arc_center")
if arc_center is not None:
construction = [
(tuple(arc_center), tuple(geom["tangent_a"])),
(tuple(arc_center), tuple(geom["tangent_b"])),
]
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
@staticmethod
def _far_endpoint(reference_line, intersection):
"""Endpoint of ``reference_line`` furthest from ``intersection``."""
p1, p2 = reference_line
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
return p2 if d2 >= d1 else p1
+84 -38
View File
@@ -38,7 +38,6 @@ 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.window import create_bm_box, create_bm_window
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
@@ -567,58 +566,103 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class _DoorEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for door parametric-edit operators. Multi-object —
iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies
to every selected door at once."""
pset_name = "BBIM_Door"
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
return tool.Blender.get_selected_objects()
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_door(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_door_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_door_modifier_representation(obj)
class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_door"
bl_label = "Cancel Editing Door on Selected Objects"
bl_description = "Cancel editing and revert door parameters to their previous values"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cancel_targets(context)
def cancel_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
core.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.cancel_editing_door_on_object(obj)
return {"FINISHED"}
class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_door"
bl_label = "Finish Editing Door on Selected Objects"
bl_description = "Apply changes and finish editing door parameters"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._finish_targets(context)
def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
door_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
door_data["lining_properties"] = lining_props
door_data["panel_properties"] = panel_props
props.is_editing = False
update_door_modifier_representation(obj)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.finish_editing_door_on_object(obj)
return {"FINISHED"}
class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_door"
bl_label = "Enable Editing Door on Selected Objects"
bl_description = "Enter edit mode to modify door parameters interactively"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._enable_targets(context)
def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMDoorProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.edit_door_on_obj(obj)
return {"FINISHED"}
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
@@ -707,8 +751,8 @@ class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin)
bl_label = "Cycle Door Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_door
props_getter = tool.Model.get_door_props
element_checker = "is_door"
props_getter = "get_door_props"
type_literal = tool.Model.DoorType
type_attr = "door_type"
@@ -835,7 +879,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
),
]
props_getter = tool.Model.get_door_props
props_getter = "get_door_props"
gizmo_pref_name = "door"
@classmethod
@@ -866,11 +910,13 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.gizmo_door_type = self.create_arc_gizmo(
special_color,
"bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=False,
)
self.gizmo_flip_arc = self.create_arc_gizmo(
inactive_color,
"bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=True,
flip_local_axes="XY",
)
@@ -893,7 +939,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update swing gizmo position and color based on editing state."""
prefs = self.get_addon_prefs()
prefs = tool.Blender.get_addon_preferences()
door_gizmo_prefs = prefs.gizmos.door
door_type_visible = self.update_gizmo_visibility(
+23 -227
View File
@@ -41,187 +41,8 @@ from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim import decorator_cache
from bonsai.bim.module.drawing.decoration import DecoratorData
# Multi-entry cache for the opening preview's dissolved-edges fallback.
# Single-entry wouldn't fit: the draw handler iterates every active opening
# per frame, each with its own mesh. Bumped wholesale on the shared
# decorator-cache token (depsgraph / undo / redo / load), one slot per
# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below —
# consulted only on world-draw-data miss, so the global wipe rarely fires in
# steady state and the simpler invalidation is enough.
_dissolved_edges_cache: dict[
tuple[int, float],
tuple[list[Vector], list[tuple[int, int]]],
] = {}
_dissolved_edges_cache_token: int = -1
def _get_cached_dissolved_edges(
mesh: bpy.types.Mesh,
angle_limit: float = radians(1.0),
) -> tuple[list[Vector], list[tuple[int, int]]]:
global _dissolved_edges_cache_token
token = decorator_cache.get_decorator_cache_token()
if token != _dissolved_edges_cache_token:
_dissolved_edges_cache.clear()
_dissolved_edges_cache_token = token
key = (mesh.session_uid, angle_limit)
cached = _dissolved_edges_cache.get(key)
if cached is not None:
return cached
result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit)
_dissolved_edges_cache[key] = result
return result
# Per-object epoch: bumped only when this specific object's transform or geometry
# updates land in the depsgraph delta. Invalidation work scales with the number
# of changed objects, not total scene size — moving one object leaves every
# other entry valid. Bumped by the depsgraph handler below; cleared on
# undo/redo/load alongside the cache dicts.
_object_epochs: dict[int, int] = {}
@bpy.app.handlers.persistent
def _bump_object_epochs_for_decoration(*args) -> None:
# depsgraph_update_post is called as (scene, depsgraph) in 4.x but the
# *args signature follows decorator_cache's defensive idiom.
depsgraph = args[1] if len(args) >= 2 else None
if depsgraph is None or not hasattr(depsgraph, "updates"):
return
for u in depsgraph.updates:
if not isinstance(u.id, bpy.types.Object):
continue
if not (u.is_updated_geometry or u.is_updated_transform):
continue
# u.id is the evaluated COW copy; the cache keys are written from the
# original Object (read by the draw handler), and session_uid can
# differ across the COW boundary. Resolve to the original before keying.
original = getattr(u.id, "original", u.id)
if original is None:
continue
uid = original.session_uid
_object_epochs[uid] = _object_epochs.get(uid, 0) + 1
@bpy.app.handlers.persistent
def _clear_decoration_caches_globally(*args) -> None:
# Undo/redo/load: depsgraph deltas can't be trusted to describe the
# transition, so wipe every per-object cache state.
_object_epochs.clear()
_world_draw_data_cache.clear()
_batch_cache.clear()
def _decoration_invalidation_hooks() -> tuple:
return (
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
def install_decoration_cache_handlers() -> None:
if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration)
for hook in _decoration_invalidation_hooks():
if _clear_decoration_caches_globally not in hook:
hook.append(_clear_decoration_caches_globally)
def uninstall_decoration_cache_handlers() -> None:
try:
bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration)
except ValueError:
pass
for hook in _decoration_invalidation_hooks():
try:
hook.remove(_clear_decoration_caches_globally)
except ValueError:
pass
# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered),
# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are
# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a
# stale entry for an object that didn't change since the last build still hits.
_world_draw_data_cache: dict[
int,
tuple[
int,
tuple[
list[tuple[float, float, float]],
list[tuple[float, float, float]],
list[tuple[int, int]],
list[tuple[int, ...]],
],
],
] = {}
def _get_cached_world_draw_data(
obj: bpy.types.Object,
) -> tuple[
list[tuple[float, float, float]],
list[tuple[float, float, float]],
list[tuple[int, int]],
list[tuple[int, ...]],
]:
uid = obj.session_uid
epoch = _object_epochs.get(uid, 0)
entry = _world_draw_data_cache.get(uid)
if entry is not None and entry[0] == epoch:
return entry[1]
mw = obj.matrix_world
verts = [tuple(mw @ v.co) for v in obj.data.vertices]
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
ios_edges_attribute = obj.data.attributes.get("ios_edges")
if ios_edges_attribute:
# Loader-curated edges: read the attribute aligned with bm.edges order.
bm = bmesh.new()
bm.from_mesh(obj.data)
edges_indices = [
tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value
]
bm.free()
line_verts = verts
else:
dissolved, edges_indices = _get_cached_dissolved_edges(obj.data)
line_verts = [tuple(mw @ v) for v in dissolved]
result = (line_verts, verts, edges_indices, tris)
_world_draw_data_cache[uid] = (epoch, result)
return result
# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch);
# lookup compares epoch to _object_epochs[uid] so other objects' batches stay
# alive when one object's depsgraph delta bumps only its own epoch. The cached
# batches reference GPU-side buffers tied to Blender's built-in shaders, which
# are themselves cached by name (gpu.shader.from_builtin returns the same
# handle each call), so they stay drawable across frames.
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
uid = cache_key[0]
epoch = _object_epochs.get(uid, 0)
entry = _batch_cache.get(cache_key)
if entry is not None and entry[0] == epoch:
return entry[1]
return None
def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None:
uid = cache_key[0]
epoch = _object_epochs.get(uid, 0)
_batch_cache[cache_key] = (epoch, batch)
class FilledOpeningGenerator:
def generate(
@@ -1120,6 +941,7 @@ class SelectBoolean(Operator):
return {"FINISHED"}
# TODO: merge with ProfileDecorator?
class DecorationsHandler:
installed = None
@@ -1129,7 +951,6 @@ class DecorationsHandler:
cls.uninstall()
handler = cls()
cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")
install_decoration_cache_handlers()
@classmethod
def uninstall(cls):
@@ -1138,46 +959,15 @@ class DecorationsHandler:
except ValueError:
pass
cls.installed = None
uninstall_decoration_cache_handlers()
def _get_or_build_batch(self, shader, shader_type, content_pos, indices=None, cache_key=None):
if cache_key is not None:
cached = _get_cached_batch_or_none(cache_key)
if cached is not None:
return cached
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return None
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
if cache_key is not None:
_store_batch_in_cache(cache_key, batch)
return batch
def draw_batch(self, shader_type, content_pos, color, indices=None, cache_key=None):
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = self._get_or_build_batch(shader, shader_type, content_pos, indices, cache_key=cache_key)
if batch is None:
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
# One batch, two draws: front pass at full color, occluded pass at
# `occluded_alpha`. Save/restore depth_test matches the pattern in
# bim/module/structural/decorator.py so callers' state survives.
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if batch is None:
return
original_depth_test = gpu.state.depth_test_get()
gpu.state.depth_test_set("LESS_EQUAL")
self.line_shader.uniform_float("color", color)
batch.draw(self.line_shader)
gpu.state.depth_test_set("GREATER")
dimmed = list(color)
dimmed[3] = occluded_alpha
self.line_shader.uniform_float("color", dimmed)
batch.draw(self.line_shader)
gpu.state.depth_test_set(original_depth_test)
def __call__(self, context):
props = tool.Model.get_model_props()
if not props.openings:
@@ -1249,20 +1039,23 @@ class DecorationsHandler:
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
else:
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
bm = bmesh.new()
bm.from_mesh(obj.data)
verts = [tuple(obj.matrix_world @ v.co) for v in bm.verts]
if ios_edges_attribute := obj.data.attributes.get("ios_edges"):
edges = [e for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value]
else:
edges = bm.edges
edges_indices = [tuple([v.index for v in e.verts]) for e in edges]
color = selected_elements_color if obj in context.selected_objects else special_elements_color
self._draw_lines_with_occlusion(line_verts, color, edges_indices, cache_key=(obj.session_uid, "lines"))
self.draw_batch(
"TRIS",
verts,
transparent_color(special_elements_color),
tris,
cache_key=(obj.session_uid, "tris"),
)
self.draw_batch("LINES", verts, color, edges_indices)
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
if "HalfSpaceSolid" in obj.name:
# Arrow shape
@@ -1276,4 +1069,7 @@ class DecorationsHandler:
]
edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)]
color = selected_elements_color if obj in context.selected_objects else special_elements_color
self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
self.draw_batch("LINES", verts, color, edges)
if obj.mode != "EDIT":
bm.free()
@@ -75,7 +75,6 @@ class PolylineOperator:
self.is_typing = False
self.snap_angle = None
self.snapping_points = []
self.unit_scale = 1.0
self.instructions = {
"Cycle Input": {"icons": True, "keys": ["EVENT_TAB"]},
"Distance Input": {"icons": True, "keys": ["EVENT_D"]},
@@ -1,221 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared helpers for Bonsai's parametric preview flows.
Multiple Bonsai features follow the same Scene-level preview pattern:
Enable<X>Preview validates a selection, populates draft state on
``Scene.BIMPreviewProperties.<x>``, flips ``is_active``.
Gizmo<X>Preview polls on ``is_active``, surfaces tunable widgets +
validate/cancel icons.
<X>PreviewDecorator GPU lines drawn while ``is_active`` is True.
Finish<X>Preview direct ``bpy.ops.bim.<verb>(...)`` call with kwargs
read off the draft state, then clears it.
Cancel<X>Preview pure state reset.
The MEP bend and wall fillet flows are the two current callers. They write
their Finish / Cancel operators directly, matching the convention used
throughout the rest of ``bim/module/model/`` for operator-to-operator
dispatch (explicit ``bpy.ops.bim.X(kwarg=value)`` at the call site, no
string indirection). This module hosts the cross-cutting accessors only;
no base class layer.
The GPU draw-handler lifecycle for ``<X>PreviewDecorator`` lives on the
feature-neutral ``tool.Blender.ViewportDecorator`` base, which every
viewport decorator (preview or otherwise) inherits from."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import bpy
import bonsai.tool as tool
# --- Props accessors ---------------------------------------------------------
def get_preview_props(context: bpy.types.Context, attr: str):
"""Resolve a child preview PropertyGroup under ``Scene.BIMPreviewProperties``.
Returns ``None`` if the umbrella isn't attached yet — true briefly
during addon register and during plug-out, so polls / draw callbacks
must defend against ``None`` rather than assuming the prop is always
available. Also tolerates contexts without a ``scene`` attribute
(test mocks built from ``SimpleNamespace``)."""
scene = getattr(context, "scene", None)
if scene is None:
return None
preview = getattr(scene, "BIMPreviewProperties", None)
return getattr(preview, attr, None) if preview is not None else None
def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
"""``True`` while a specific preview is open. Used by sibling gizmo
polls to hide themselves so the preview is the only interactive
surface in the viewport (the bend / fillet preview groups take over
the same selection's icon stack)."""
props = get_preview_props(context, attr)
return bool(props is not None and props.is_active)
def any_preview_active(context: bpy.types.Context) -> bool:
"""``True`` if any registered preview is currently open. Sister gizmo
polls call this to hide themselves uniformly during ANY preview, so a
new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates
every parametric gizmo without each one growing a specific check."""
for attr, _op_name in PREVIEW_CANCEL_OPS:
if is_preview_active(context, attr):
return True
return False
# --- Lazy closure factories --------------------------------------------------
#
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
# ``move_get_cb`` / ``move_set_cb`` callbacks. The closures re-resolve
# ``bpy.context.scene`` per CALL rather than capturing it at setup() time
# — the captured Scene's RNA struct can be freed on file open / undo, and
# referencing a freed struct crashes Blender. Lazy lookup survives the
# whole undo / reload lifecycle.
def make_props_callback(attr: str) -> Callable[[], Any]:
"""Return a zero-arg callable that lazily fetches the preview props.
Equivalent to ``getattr(bpy.context.scene.BIMPreviewProperties, attr)``
with full defensiveness against missing scene / missing umbrella."""
def _props():
scene = bpy.context.scene
preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
return getattr(preview, attr, None) if preview is not None else None
return _props
def make_dim_getter(props_callback: Callable[[], Any], field: str) -> Callable[[], float]:
"""Factory for ``BIM_GT_gizmo_dimension.move_get_cb`` reading a single
FloatProperty off the live preview state. Returns ``0.0`` defensively
when the props are temporarily unavailable so the widget doesn't crash
Blender during plug-out / reload."""
def _get() -> float:
props = props_callback()
return getattr(props, field) if props is not None else 0.0
return _get
def make_dim_setter(
props_callback: Callable[[], Any],
field: str,
min_value: float = 0.001,
) -> Callable[[float], None]:
"""Factory for ``BIM_GT_gizmo_dimension.move_set_cb`` writing a single
FloatProperty + tagging viewport areas for redraw so the GPU preview
decorator tracks the value live during drag. Clamps at ``min_value``
to match the FloatProperty's declared lower bound."""
def _set(value: float) -> None:
props = props_callback()
if props is None:
return
setattr(props, field, max(min_value, float(value)))
tool.Blender.update_all_viewports()
return _set
# --- Shared Enable lifecycle helpers -----------------------------------------
def sync_uncommitted_moves(objects: list) -> None:
"""Push any Blender-side translation / rotation of ``objects`` back to
their IFC ``ObjectPlacement`` before a preview decorator starts reading
``obj.matrix_world`` per frame.
Without this sync, a user who grabbed-moved an object but didn't commit
the move sees the live preview at the dragged position while the final
commit lands at the stale IFC position a confusing "where did my
preview go?" experience. Both bend and fillet enable paths call this
on the relevant pair just before activating the preview."""
for obj in objects:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
# --- Esc dispatch ------------------------------------------------------------
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
("bend", "cancel_bend_preview"),
("wall_fillet", "cancel_wall_fillet_preview"),
)
"""Registry of ``(child PointerProperty on Scene.BIMPreviewProperties, bim
operator name)`` consulted by the Esc handler. Adding a new preview means
appending one tuple; the forward-compat test pins that every preview
PropertyGroup with ``is_active`` has an entry here."""
def try_cancel_active_preview(context: bpy.types.Context) -> bool:
"""Cancel every registered preview that is currently active.
Returns ``True`` iff at least one preview was cancelled. Multiple
previews can be simultaneously active (e.g. a stale bend preview opened
just before the user starts a wall fillet) one Esc must clear them
all rather than forcing the user to tap Esc once per preview.
Tags 3D viewports for redraw on success the Esc keymap entry runs
outside a viewport mouse event so the gizmo poll wouldn't re-evaluate
until the next interaction without an explicit redraw."""
cancelled = False
for attr, op_name in PREVIEW_CANCEL_OPS:
if is_preview_active(context, attr):
getattr(bpy.ops.bim, op_name)()
cancelled = True
if cancelled:
tool.Blender.update_all_viewports(context)
return cancelled
def discard_pending_previews(scene: bpy.types.Scene) -> None:
"""Clear every active preview under ``Scene.BIMPreviewProperties`` so
saved preview state never resurfaces on file load.
Mirrors ``tool.Parametric.heal_stale_edit_flags`` for the object-level
parametric-edit lifecycle except previews are *discarded* rather than
validated. A preview's only UI cue is its in-viewport widget; reloading
a ``.blend`` saved mid-preview restores the flag but not the surrounding
user attention, and a stuck ``is_active`` silently hides every sibling
gizmo poll gated on it.
Iterates ``PREVIEW_CANCEL_OPS`` so any preview registered for Esc
cancellation is automatically covered here too. Sets ``is_active``
directly rather than dispatching the cancel operator: load_post may
fire before ``bpy.context.screen`` is reattached, and the cancel
operators bail on ``context.screen is None``."""
preview = getattr(scene, "BIMPreviewProperties", None)
if preview is None:
return
for attr, _op_name in PREVIEW_CANCEL_OPS:
child = getattr(preview, attr, None)
if child is not None and getattr(child, "is_active", False):
child.is_active = False
-202
View File
@@ -15,8 +15,6 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import math
from collections.abc import Callable
@@ -195,32 +193,6 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None
_get_updater("stair", "regenerate_stair_mesh")(obj)
def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Regenerate wall mesh preview when property changes. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC.
``offset`` itself has no ``update`` callback on purpose adding one would make
every baseline cycle rebuild the bmesh twice (once via offset's callback, once
explicitly below)."""
obj = context.active_object
if not (obj and self.is_editing):
return
t = self.thickness
if self.desired_offset_baseline == "CENTER":
self.offset = -t / 2
elif self.desired_offset_baseline == "INTERIOR":
self.offset = -t
else: # EXTERIOR
self.offset = 0.0
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
@@ -1659,118 +1631,6 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing.
Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
(preview only no IFC writes), and either committed by `bim.finish_editing_wall`
or discarded by `bim.cancel_editing_wall`.
The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
current vs snap to skip unchanged params and guarantee a no-op session leaves the
IFC file byte-identical.
"""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while wall parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview box; cleared once the real "
"IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall length along its reference axis (preview value; committed on finish).",
)
height: bpy.props.FloatProperty(
name="Height",
default=3.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall vertical height (preview value; committed on finish).",
)
x_angle: bpy.props.FloatProperty(
name="Slope (X Angle)",
default=0.0,
soft_min=-math.pi / 3,
soft_max=math.pi / 3,
subtype="ANGLE",
update=update_wall,
description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
)
thickness: bpy.props.FloatProperty(
name="Thickness",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
)
offset: bpy.props.FloatProperty(
name="Offset",
default=0.0,
subtype="DISTANCE",
description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
)
desired_offset_baseline: bpy.props.EnumProperty(
items=[
("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
("CENTER", "Center", "Reference axis at the wall centreline"),
("INTERIOR", "Interior", "Reference axis at the interior face"),
],
name="Desired Offset Baseline",
default="CENTER",
update=update_wall_offset_baseline,
description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
)
anchor_x: bpy.props.FloatProperty(
default=0.0,
subtype="DISTANCE",
description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
)
snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
snap_thickness: bpy.props.FloatProperty(
description="Snapshot of thickness at edit-enable; commit skips no-op writes."
)
snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
snap_x_angle: bpy.props.FloatProperty(
subtype="ANGLE",
description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
)
snap_offset_baseline: bpy.props.StringProperty(
default="",
description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
height: float
x_angle: float
thickness: float
offset: float
desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
anchor_x: float
snap_length: float
snap_height: float
snap_thickness: float
snap_offset: float
snap_x_angle: float
snap_offset_baseline: str
class SnapMousePoint(PropertyGroup):
x: bpy.props.FloatProperty(name="X")
y: bpy.props.FloatProperty(name="Y")
@@ -1902,65 +1762,3 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class BIMWallFilletPreviewProperties(PropertyGroup):
"""Scene-level pending state for the wall-fillet preview flow.
Scene-level because the fillet spans two walls and commits a third
(corner) wall between them. ``SKIP_SAVE`` fields throughout."""
is_active: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
description="True while the wall-fillet preview flow is active.",
)
wall_a_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of the active wall — the corner wall inherits its "
"material layer set, height, x_angle, and type."
),
)
wall_b_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the other selected wall.",
)
radius: bpy.props.FloatProperty(
name="Radius",
default=0.5,
soft_min=-10.0,
soft_max=10.0,
subtype="DISTANCE",
unit="LENGTH",
options={"SKIP_SAVE"},
description="Radius of the circular arc connecting the two walls.",
)
editing_corner_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of an existing fillet corner being re-edited "
"(non-zero only on the pen-icon re-edit flow). The create "
"operator deletes this corner + its connections before recreating "
"with the new radius."
),
)
if TYPE_CHECKING:
is_active: bool
wall_a_id: int
wall_b_id: int
radius: float
editing_corner_id: int
class BIMPreviewProperties(PropertyGroup):
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
if TYPE_CHECKING:
wall_fillet: BIMWallFilletPreviewProperties
+45 -44
View File
@@ -34,7 +34,6 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -93,6 +92,7 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
representation_data = {
"railing_type": props.railing_type,
"context": body,
"railing_path": railing_path,
"use_manual_supports": props.use_manual_supports,
@@ -406,65 +406,66 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class _RailingEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for railing parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
pset_name = "BBIM_Railing"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_railing(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_railing_props(obj)
@classmethod
def _post_load_data(cls, data: dict) -> dict:
# BIMRailingProperties.path_data is a StringProperty holding JSON.
data["path_data"] = json.dumps(data["path_data"])
return data
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_railing_pset(element, data)
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_ifc_data(context)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_bmesh(context)
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_railing"
bl_label = "Enable Editing Railing"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context):
return self._enable_targets(context)
obj = context.active_object
assert obj
props = tool.Model.get_railing_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
data["path_data"] = json.dumps(data["path_data"])
# required since we could load pset from .ifc and BIMRailingProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_railing"
bl_label = "Cancel Editing Railing"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context):
return self._cancel_targets(context)
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
props = tool.Model.get_railing_props(obj)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
update_railing_modifier_bmesh(context)
props.is_editing = False
return {"FINISHED"}
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_railing"
bl_label = "Finish Editing Railing"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context):
return self._finish_targets(context)
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_railing_props(obj)
pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing")
path_data = pset_data["data_dict"]["path_data"]
railing_data = props.get_general_kwargs(convert_to_project_units=True)
railing_data["path_data"] = path_data
props.is_editing = False
update_bbim_railing_pset(element, railing_data)
update_railing_modifier_ifc_data(context)
return {"FINISHED"}
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
+39 -38
View File
@@ -34,7 +34,6 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -609,59 +608,61 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.add_body_representation(obj)
class _RoofEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for roof parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRoofPath`` operators handle path editing."""
pset_name = "BBIM_Roof"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_roof(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_roof_props(obj)
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_roof_pset(element, data)
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_ifc_data(context)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context):
return self._enable_targets(context)
obj = context.active_object
assert obj
props = tool.Model.get_roof_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
# required since we could load pset from .ifc and BIMRoofProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context):
return self._cancel_targets(context)
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
props = tool.Model.get_roof_props(obj)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
update_roof_modifier_bmesh(obj)
props.is_editing = False
return {"FINISHED"}
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context):
return self._finish_targets(context)
obj = context.active_object
element = tool.Ifc.get_entity(obj)
props = tool.Model.get_roof_props(obj)
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
path_data = pset_data["data_dict"]["path_data"]
roof_data = props.get_general_kwargs(convert_to_project_units=True)
roof_data["path_data"] = path_data
props.is_editing = False
update_bbim_roof_pset(element, roof_data)
update_roof_modifier_ifc_data(context)
return {"FINISHED"}
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
+24 -18
View File
@@ -15,8 +15,6 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import json
@@ -264,6 +262,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# Use the special method that includes custom_tread_lock for IFC storage
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
props.is_editing = False
regenerate_stair_mesh(obj)
tool.Model.add_body_representation(obj)
@@ -273,7 +272,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# update IfcStairFlight properties
update_ifc_stair_props(obj)
props.is_editing = False
return {"FINISHED"}
@@ -430,7 +428,7 @@ class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
bl_label = "Cycle Stair Type"
bl_options = {"REGISTER", "UNDO"}
props_getter = tool.Model.get_stair_props
props_getter = "get_stair_props"
type_literal = tool.Model.StairType
type_attr = "stair_type"
skip_element_check = True
@@ -580,7 +578,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
]
# Metadata-driven dispatch for props and preferences
props_getter = tool.Model.get_stair_props
props_getter = "get_stair_props"
gizmo_pref_name = "stair"
@classmethod
@@ -593,12 +591,14 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"VIEW3D_GT_lock",
self.COLOR_BLUE,
"bim.toggle_stair_property",
prop_path="BIMStairProperties.total_length_lock",
property_name="total_length_lock",
)
self.tread_lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock",
(1.0, 1.0, 1.0),
"bim.toggle_stair_property",
prop_path="BIMStairProperties.custom_tread_lock",
property_name="custom_tread_lock",
)
self.plus_gizmo = self.create_icon_gizmo(
@@ -608,23 +608,29 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update stair-specific lock and tread count gizmos. Lock positioning is
handled per-frame in the dimension-positioning hook."""
self.update_lock_gizmo(props)
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
"""Update stair-specific lock and tread count gizmos."""
billboard_rot = gizmo.get_billboard_rotation(context)
self.update_lock_gizmo(mw, props, billboard_rot)
self.update_tread_lock_gizmo(props)
self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update lock gizmo color and visibility. Positioning is handled
per-frame by the dimension-positioning hook."""
def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
"""Update lock gizmo visibility, color, and position."""
gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip color update
return # Hidden, skip positioning
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
total_run = props.get_total_run()
local_transform = (
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
@ billboard_rot
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
)
self.lock_gizmo.matrix_basis = mw @ local_transform
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
@@ -644,11 +650,11 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
) -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
billboard_rot = self._frame_billboard_rot
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
billboard_rot = gizmo.get_billboard_rotation(context)
total_run = props.get_total_run()
riser_height = props.get_riser_height()
+3 -45
View File
@@ -24,7 +24,6 @@ from typing import TYPE_CHECKING, Any
import bpy
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
@@ -304,8 +303,6 @@ class BIM_PT_stair(bpy.types.Panel):
row = self.layout.row(align=True)
row.label(text="Stair parameters", icon="IPO_CONSTANT")
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
if props.is_editing:
calculated_params = tool.Model.get_active_stair_calculated_params()
row = self.layout.row(align=True)
@@ -325,61 +322,22 @@ class BIM_PT_stair(bpy.types.Panel):
row.label(text=f"{prop_name}:")
row = self.layout.row(align=True)
for prop_value_item in prop_value:
if isinstance(prop_value_item, float):
row.label(text=tool.Unit.format_distance(prop_value_item * si_conversion))
else:
row.label(text=str(prop_value_item))
row.label(text=str(prop_value_item))
else:
row.label(text=prop_name)
if isinstance(prop_value, float):
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
else:
row.label(text=str(prop_value))
row.label(text=str(prop_value))
# calculated properties
for prop_name, prop_value in calculated_params.items():
row = self.layout.row(align=True)
row.label(text=prop_name)
if isinstance(prop_value, float):
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
else:
row.label(text=str(prop_value))
row.label(text=str(prop_value))
else:
row = self.layout.row()
row.label(text="No Stair Found")
row.operator("bim.add_stair", icon="ADD", text="")
class BIM_PT_wall(bpy.types.Panel):
bl_label = "Wall"
bl_idname = "BIM_PT_wall"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_options = {"DEFAULT_CLOSED"}
bl_parent_id = "BIM_PT_tab_parametric_geometry"
@classmethod
def poll(cls, context):
obj = context.active_object
if not obj:
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and tool.Blender.Modifier.is_wall(element)
def draw(self, context):
obj = context.active_object
if obj is None:
return
props = tool.Model.get_wall_props(obj)
row = self.layout.row(align=True)
if props.is_editing:
row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
else:
row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
class BIM_PT_sverchok(bpy.types.Panel):
bl_label = "Sverchok"
bl_idname = "BIM_PT_sverchok"
File diff suppressed because it is too large Load Diff
+68 -32
View File
@@ -39,7 +39,6 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWindowProperties
@@ -483,53 +482,90 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class _WindowEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for window parametric-edit operators. Single-object
by design (window edits target the active object only)."""
pset_name = "BBIM_Window"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_window(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_window_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_window_modifier_representation(context)
class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_window"
bl_label = "Cancel Editing Window"
bl_description = "Cancel editing and revert window parameters to their previous values"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cancel_targets(context)
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props = tool.Model.get_window_props(obj)
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
return {"FINISHED"}
class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_window"
bl_label = "Finish Editing Window"
bl_description = "Apply changes and finish editing window parameters"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._finish_targets(context)
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_window_props(obj)
window_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
window_data["lining_properties"] = lining_props
window_data["panel_properties"] = panel_props
props.is_editing = False
update_window_modifier_representation(context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Window")
window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data})
return {"FINISHED"}
class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_window"
bl_label = "Enable Editing Window"
bl_description = "Enter edit mode to modify window parameters interactively"
bl_options = {"REGISTER", "UNDO"}
bl_options = {"REGISTER"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._enable_targets(context)
obj = context.active_object
assert obj
props = tool.Model.get_window_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMWindowProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
@@ -558,8 +594,8 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixi
bl_label = "Cycle Window Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_window
props_getter = tool.Model.get_window_props
element_checker = "is_window"
props_getter = "get_window_props"
type_literal = tool.Model.WindowType
type_attr = "window_type"
@@ -745,7 +781,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
]
props_getter = tool.Model.get_window_props
props_getter = "get_window_props"
gizmo_pref_name = "window"
@classmethod
+13 -13
View File
@@ -841,7 +841,7 @@ class EditObjectUI:
row = cls.layout.row(align=True)
row.separator()
row.label(text="Operations") if ui_context != "TOOL_HEADER" else row
cls.draw_regen_operations(row, ui_context)
cls.draw_regen_operations(row)
if AuthoringData.data["active_material_usage"] == "LAYER2":
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
@@ -962,14 +962,20 @@ class EditObjectUI:
return row
@classmethod
def draw_regen_operations(cls, row, ui_context):
def draw_regen_operations(cls, row):
custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id
if AuthoringData.data["is_regenable_element"]:
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)
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
description = "Recalculate Element Geometry\nHotkey: S G"
op.hotkey = "S_G"
op.description = description.strip()
if PortData.data["total_ports"] > 0:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
description = f"{bpy.ops.bim.regenerate_distribution_element.__doc__}\n\nHotkey: S G"
op.hotkey = "S_G"
op.description = description.strip()
@classmethod
def draw_void(cls, context, row):
@@ -1294,15 +1300,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.generate_space()
return
if self.active_material_usage == "LAYER2":
if element and tool.Model.has_underside_connection(element):
bpy.ops.bim.regenerate_wall_to_underside()
else:
bpy.ops.bim.recalculate_wall()
bpy.ops.bim.recalculate_wall()
elif self.active_material_usage == "LAYER3":
bpy.ops.bim.recalculate_slab()
wall_objs = tool.Model.get_connected_wall_objs(element)
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
elif tool.System.get_ports(element):
bpy.ops.bim.regenerate_distribution_element()
elif self.active_material_usage == "PROFILE":
@@ -15,8 +15,6 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import datetime
import json
@@ -63,7 +61,6 @@ import bonsai.core.project as core
import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
@@ -1906,11 +1903,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
props = tool.Blender.get_bim_props()
filepath = props.ifc_file
if not filepath or self.should_save_as:
return ExportHelper.invoke(self, context, event)
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
if (filepath := props.ifc_file) and not self.should_save_as:
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
return ExportHelper.invoke(self, context, event)
def check(self, context):
# ExportHelper is automatically adjusting suffix to `filename_ext`.
@@ -1936,20 +1933,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return {"FINISHED"}
def _execute(self, context):
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
# persisted through the save would silently hide them on reload.
preview_base.discard_pending_previews(context.scene)
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else ""
if failed_commits:
names = ", ".join(o.name for o in failed_commits)
msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}"
print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).")
self.report({"ERROR"}, msg)
start = time.time()
logger = logging.getLogger("ExportIFC")
path_log = tool.Blender.get_data_dir_path("process.log")
@@ -2018,7 +2001,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
blendmetadata_path = output_file + suffix
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}',
)
except Exception as e:
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
@@ -2028,7 +2011,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath)
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved{commit_suffix}',
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved',
)
bonsai.bim.handler.refresh_ui_data()
@@ -302,15 +302,6 @@ class SelectSimilarContainer(bpy.types.Operator):
is_recursive=self.is_recursive,
)
self.is_recursive = True # <-- forcibly reset
element = tool.Ifc.get_entity(context.active_object)
if element:
container = tool.Spatial.get_container(element)
if container:
result = f'location="{container.Name}"'
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
return {"FINISHED"}
+11 -29
View File
@@ -118,19 +118,6 @@ def update_shader_graph(self: Union["Texture", "BIMStylesProperties"], context:
tool.Loader.create_surface_style_with_textures(material, shading_data, textures_data)
def _make_clear_null_updater(null_prop: str):
def _update(self: "BIMStylesProperties", context: bpy.types.Context) -> None:
self[null_prop] = False
update_shader_graph(self, context)
return _update
update_diffuse_colour = _make_clear_null_updater("is_diffuse_colour_null")
update_specular_colour = _make_clear_null_updater("is_specular_colour_null")
update_specular_highlight_value = _make_clear_null_updater("is_specular_highlight_null")
UV_MODES = [
("UV", "UV", _("Actual UV data presented on the geometry")),
("Generated", "Generated", _("Automatically-generated UV from the vertex positions of the mesh")),
@@ -234,29 +221,24 @@ class BIMStylesProperties(PropertyGroup):
transparency: bpy.props.FloatProperty(
name="Transparency", default=0.0, min=0.0, max=1.0, update=update_shader_graph
)
is_diffuse_colour_null: BoolProperty(name="Is Null", update=update_shader_graph)
# TODO: do something on null?
is_diffuse_colour_null: BoolProperty(name="Is Null")
diffuse_colour_class: EnumProperty(
items=[(x, x, "") for x in get_args(ColourClass)],
name="Diffuse Colour Class",
update=update_diffuse_colour,
update=update_shader_graph,
)
diffuse_colour: bpy.props.FloatVectorProperty(
name="Diffuse Colour",
subtype="COLOR",
default=(1, 1, 1),
min=0.0,
max=1.0,
size=3,
update=update_diffuse_colour,
name="Diffuse Colour", subtype="COLOR", default=(1, 1, 1), min=0.0, max=1.0, size=3, update=update_shader_graph
)
diffuse_colour_ratio: bpy.props.FloatProperty(
name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_diffuse_colour
name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_shader_graph
)
is_specular_colour_null: BoolProperty(name="Is Null", update=update_shader_graph)
is_specular_colour_null: BoolProperty(name="Is Null")
specular_colour_class: EnumProperty(
items=[(x, x, "") for x in get_args(ColourClass)],
name="Specular Colour Class",
update=update_specular_colour,
update=update_shader_graph,
default="IfcNormalisedRatioMeasure",
)
specular_colour: bpy.props.FloatVectorProperty(
@@ -266,7 +248,7 @@ class BIMStylesProperties(PropertyGroup):
min=0.0,
max=1.0,
size=3,
update=update_specular_colour,
update=update_shader_graph,
)
specular_colour_ratio: bpy.props.FloatProperty(
name="Specular Ratio",
@@ -274,16 +256,16 @@ class BIMStylesProperties(PropertyGroup):
default=0.0,
min=0.0,
max=1.0,
update=update_specular_colour,
update=update_shader_graph,
)
is_specular_highlight_null: BoolProperty(name="Is Null", update=update_shader_graph)
is_specular_highlight_null: BoolProperty(name="Is Null")
specular_highlight: bpy.props.FloatProperty(
name="Specular Highlight",
description="Used as Roughness value in PHYSICAL Reflectance Method",
default=0.0,
min=0.0,
max=1.0,
update=update_specular_highlight_value,
update=update_shader_graph,
)
reflectance_method: EnumProperty(
name="Reflectance Method",
+1 -2
View File
@@ -151,8 +151,7 @@ class BIM_PT_type_attributes(Panel):
row = layout.row(align=True)
row.label(text=attribute["name"])
value = get_display_value(attribute["value"])
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
op.key = "type." + attribute["name"]
row.label(text=value)
def add_object_button(self, context):
@@ -1,611 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared operator mixins for parametric-edit operators.
Edit-lifecycle mixins (Enable / Finish / Cancel):
`FeatureModifierEditMixin` door, window (BBIM_<Type> pset; nested
lining/panel properties; Finish + Cancel route through
``ifcopenshell.api.feature``).
`PathPreservingEditMixin` railing, roof (path_data preserved across
edit; only general kwargs are user-editable).
Pattern selection (which approach a new feature should adopt):
Every parametric edit lifecycle commits to one of three patterns. Pick by
answering "does the feature share the Enable→Finish→Cancel shape that
one of the existing mixins already encodes?":
A. Inherit one of the shared mixins below and route through
`tool.Parametric.build_edit_lifecycle`:
- `FeatureModifierEditMixin` when the feature stores its pset as
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
and Finish must call a per-type `update_<type>_modifier_representation`.
- `PathPreservingEditMixin` when the feature's pset carries a
`path_data` field that survives general-kwarg edits untouched, with
a separate Enable/Finish/Cancel lifecycle for path editing itself.
B. Write a per-feature mixin that subclasses `ParametricEditMixinBase`
and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`,
then route through `build_edit_lifecycle`. Pick this when the
feature's pset roundtrip or representation handling diverges from the
shared mixins but the EnableFinishCancel shape still fits.
C. Declare standalone Enable/Finish/Cancel Operator subclasses (no
factory) when the feature's parameter-change logic is sufficiently
unique that even a per-feature mixin would force optional hooks or
dead branches. Such operators MUST call the matrix_world drift
helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish,
`tool.Geometry.restore_or_rebaseline_placement` on Cancel) the
drift contract is enforced uniformly regardless of which pattern the
operators adopt.
The authoritative list of registered parametric types and which use
`build_edit_lifecycle` vs. standalone operators lives in
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
contract tests in `test/bim/test_parametric_registry.py`.
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
The lightweight parametric registry consumed at addon-enable time must stay
free of such imports and lives separately in ``tool/parametric.py``."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import ClassVar, get_args
import bpy
import ifcopenshell.util.element
from bpy.app.handlers import persistent
from ifcopenshell import entity_instance
import bonsai.core.geometry
import bonsai.tool as tool
class ParametricEditMixinBase:
"""Common scaffolding for parametric edit-lifecycle mixins.
Each per-type subclass provides four hooks:
``pset_name``: BBIM_<Type> pset identifier
``_is_element_type(element)``: IFC element predicate
``_get_props(obj)``: PropertyGroup accessor
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
Drift handling is built in: pre-edit matrix_world drift commits to IFC on
Enable, in-edit drag commits on Finish, and Cancel restores the committed
IFC placement. This prevents an uncommitted drag from disappearing on
Finish or snapping back on Cancel.
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` from their ``_execute`` method."""
pset_name: ClassVar[str]
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
obj = context.active_object
return [obj] if obj else []
@classmethod
def _is_element_type(cls, element: entity_instance) -> bool:
raise NotImplementedError
@classmethod
def _get_props(cls, obj: bpy.types.Object):
raise NotImplementedError
@classmethod
def _resolve(cls, obj: bpy.types.Object):
"""Look up ``(element, props)`` for ``obj`` if it matches this type, else None.
Common predicate guard for every lifecycle method collapses the
``element = tool.Ifc.get_entity(obj); assert element; if not is_<type>(element): return``
triplet into one call."""
element = tool.Ifc.get_entity(obj)
if not element or not cls._is_element_type(element):
return None
return element, cls._get_props(obj)
@classmethod
def _handle_drift_on_enable(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
@classmethod
def _handle_drift_on_finish(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj)
@classmethod
def _handle_drift_on_cancel(cls, obj: bpy.types.Object, element: entity_instance) -> None:
tool.Geometry.restore_or_rebaseline_placement(obj, element)
@classmethod
def _mark_type_thumbnail_dirty(cls, element: entity_instance) -> None:
"""Mark the element's type's preview thumbnail for refresh so the
property-panel preview reflects post-edit geometry. No-op for
occurrences without a backing type."""
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
class FeatureModifierEditMixin(ParametricEditMixinBase):
"""Lifecycle for door- and window-style parametric modifier operators.
Enable:
Read BBIM_<Type> pset JSON unwrap ``lining_properties`` and
``panel_properties`` merge constituents data set draft props
``is_editing = True``.
Finish:
Gather ``general / lining / panel`` kwargs (project units) nest
``is_editing = False`` call ``_update_modifier_representation``
mark thumbnail write back to BBIM_<Type> pset via
``ifcopenshell.api.pset.edit_pset``.
Cancel:
Read BBIM_<Type> pset JSON unwrap restore draft props
``switch_representation`` to the Body representation
``is_editing = False``."""
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: call the per-type ``update_<type>_modifier_representation``."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
cls._handle_drift_on_enable(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since the pset can be loaded from .ifc and the PropertyGroup
# would otherwise still hold its default values
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
data = props.get_general_kwargs(convert_to_project_units=True)
data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True)
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
cls._update_modifier_representation(obj, context)
cls._mark_type_thumbnail_dirty(element)
tool.Pset.write_bbim_data(element, cls.pset_name, data)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
# Cancel must always clear is_editing — leaving it True after a
# restore-failure would block the user from re-entering edit mode and
# the next save's stale-flag heal would silently roll back the
# cancellation. Wrap the restore in try/finally so the flag flips
# even on partial failure.
try:
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props.set_props_kwargs_from_ifc_data(data)
body = tool.Geometry.get_body_representation(element)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
cls._handle_drift_on_cancel(obj, element)
finally:
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj)
return {"FINISHED"}
class PathPreservingEditMixin(ParametricEditMixinBase):
"""Lifecycle for railing- and roof-style parametric modifier operators.
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
user-editable through this lifecycle it survives the edit untouched, only
general kwargs are diffed. (Path editing has its own separate operator
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
Enable:
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` set
draft props ``is_editing = True``. The subclass post-load hook
can reshape the dict to fit the PropertyGroup's storage layout
(e.g., pre-serialise a structured pset value to JSON for a
``StringProperty`` field).
Finish:
Read fresh pset keep ``path_data`` gather ``general`` kwargs
(project units) reassemble ``is_editing = False`` call
``_update_pset`` (per-type pset writer) call ``_update_modifier_ifc_data``
(per-type geometry commit).
Cancel:
Read fresh pset restore draft props call
``_restore_viewport_after_cancel`` (per-type viewport restore typically
rebuilds the bmesh preview, but subclasses may load a different
representation entirely) ``is_editing = False``."""
@classmethod
def _post_load_data(cls, data: dict) -> dict:
"""Hook: optionally transform the pset data dict after loading and before
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
Override when the PropertyGroup stores a structured pset field as a
serialised primitive e.g., a list/dict value mapped onto a
``StringProperty`` requires JSON-encoding here."""
return data
@classmethod
def _update_pset(cls, element: entity_instance, data: dict) -> None:
"""Hook: per-type pset writer (``update_bbim_<type>_pset``)."""
raise NotImplementedError
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: per-type ``update_<type>_modifier_ifc_data`` — commits the
modified geometry to IFC. Signature accepts ``(obj, context)`` so
subclasses can forward either argument to their existing helper."""
raise NotImplementedError
@classmethod
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: restore the viewport mesh to match the just-restored draft props.
Most subclasses rebuild a bmesh preview from props. Subclasses whose
committed IFC representation diverges from the preview may switch
the mesh back to the committed representation instead."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
cls._handle_drift_on_enable(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
data = cls._post_load_data(data)
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
data = props.get_general_kwargs(convert_to_project_units=True)
data["path_data"] = stored["path_data"]
# Skip the pset commit when the draft is identical to the stored pset:
# an Enable → Finish-without-changes cycle should not pollute the
# representation list or burn an undo entry. Drift commit still runs
# unconditionally — matrix_world drift is independent of pset content.
if data != stored:
cls._update_pset(element, data)
cls._update_modifier_ifc_data(obj, context)
cls._mark_type_thumbnail_dirty(element)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
try:
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
draft = props.get_general_kwargs(convert_to_project_units=True)
draft["path_data"] = stored["path_data"]
nothing_changed = draft == stored
data = cls._post_load_data(stored)
props.set_props_kwargs_from_ifc_data(data)
# Skip the viewport rebuild on a no-op cancel: the mesh on screen is
# still the committed representation, and the per-type viewport-restore
# hook may be expensive (some subclasses reload a high-poly IFC
# representation rather than rebuild a preview mesh).
if not nothing_changed:
cls._restore_viewport_after_cancel(obj, context)
cls._handle_drift_on_cancel(obj, element)
finally:
# Always clear the flag — see ``FeatureModifierEditMixin._cancel_one``
# for the rationale.
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj, context)
return {"FINISHED"}
# --- Type-selection mixins (Cycle / Pick) ------------------------------------
class TypeAccessorBase:
"""Shared contract for operators that resolve and write a Literal type
attribute on a Bonsai PropertyGroup.
Subclasses define ``element_checker``, ``props_getter``, ``type_literal``,
``type_attr``; ``skip_element_check`` bypasses element validation. Concrete
subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction
shape on top.
Test doubles must be set on the operator instance the predicates are
bound at class-definition time, so patching the underlying tool module
has no effect."""
element_checker: Callable[[entity_instance], bool]
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup]
type_literal: type
type_attr: str
skip_element_check: bool = False
def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None:
"""Return the active object iff it passes ``element_checker`` (or the
check is skipped). ``None`` signals the operator should bail with
``{'CANCELLED'}``."""
obj = context.active_object
if not obj:
return None
if not self.skip_element_check:
element = tool.Ifc.get_entity(obj)
if not element or not self.element_checker(element):
return None
return obj
class CycleTypeMixin(TypeAccessorBase):
"""Operator mixin that cycles through ``type_literal``'s values.
Shift-click reverses direction."""
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
self.reverse = event.shift
return self.execute(context)
def _cycle_type(self, context: bpy.types.Context) -> set[str]:
obj = self._resolve_target(context)
if obj is None:
return {"CANCELLED"}
props = self.props_getter(obj)
types = get_args(self.type_literal)
current = getattr(props, self.type_attr)
idx = types.index(current) if current in types else 0
direction = -1 if self.reverse else 1
setattr(props, self.type_attr, types[(idx + direction) % len(types)])
return {"FINISHED"}
class PickTypeMixin(TypeAccessorBase):
"""Operator mixin that opens a popup menu listing ``type_literal``'s values.
Empty ``value`` ``invoke`` opens the popup; non-empty the user picked
an item and ``_pick_type`` applies it.
When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the
menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise
the still-pressed click flows straight into Blender's drag-through-pick
gesture and the menu commits whichever item the cursor drifts over on
release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6
redo) bypass the wait and open the menu immediately.
The ``value`` StringProperty is declared on this mixin but registered via
the concrete Operator subclass's MRO scan — do not instantiate the mixin
standalone."""
# Carries the picked value through invoke→execute; empty default
# distinguishes "open popup" from "apply".
value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
"""Open the picker menu, or apply a value that was preset by a
menu-item click.
Routing through ``execute()`` keeps subclass IFC-transaction wrapping
in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path."""
if self.value:
return self.execute(context)
if self._resolve_target(context) is None:
return {"CANCELLED"}
if event.value == "PRESS":
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
return self._open_picker(context)
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self._open_picker(context)
# INTERFACE does not remove a modal handler; only FINISHED /
# CANCELLED do.
return {"CANCELLED"}
if event.type in {"RIGHTMOUSE", "ESC"}:
return {"CANCELLED"}
return {"RUNNING_MODAL"}
def _open_picker(self, context: bpy.types.Context) -> set[str]:
bl_idname = self.bl_idname
values = list(get_args(self.type_literal))
def draw(menu_self, _menu_context):
layout = menu_self.layout
for v in values:
op = layout.operator(bl_idname, text=v)
op.value = v
context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL")
# The type change is a two-step interaction: this invocation just OPENS
# the menu (no state change yet); a SECOND invocation fires when the
# user clicks a menu item — that one writes ``props.<type_attr>`` and
# returns FINISHED. By returning INTERFACE here (and not FINISHED), the
# menu-open step is excluded from Blender's undo stack so the user
# gets exactly ONE undo entry per type change. If we returned FINISHED
# here too, the stack would gain a no-op "opened the menu" entry that
# Ctrl+Z would dismiss before reverting the actual type change —
# confusing UX where the first Ctrl+Z appears to do nothing.
return {"INTERFACE"}
def _pick_type(self, context: bpy.types.Context) -> set[str]:
if not self.value:
# No-op rather than re-open the menu, so command-palette misuse
# doesn't infinite-loop.
return {"CANCELLED"}
obj = self._resolve_target(context)
if obj is None:
return {"CANCELLED"}
if self.value not in get_args(self.type_literal):
self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}")
return {"CANCELLED"}
props = self.props_getter(obj)
setattr(props, self.type_attr, self.value)
return {"FINISHED"}
# --- Undo-resync registry ----------------------------------------------------
#
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
# (wired into ``bim/handler.py:undo_post`` and ``redo_post``) so the preview
# mesh of an in-progress parametric draft repaints after Ctrl+Z / Ctrl+Shift+Z.
#
# Each regenerator is a one-line lazy-import + call. Lazy imports because
# ``bonsai.bim.parametric_lifecycle`` loads before ``bim/module/model/*``
# at addon enable; a module-level import would cycle. Each function-local
# import lands at first call, after the feature module has registered.
#
# Types with no entry — door, window, railing, etc. — are IFC-derived: undo
# of an IFC mutation already restores the entity, and ``switch_representation``
# repaints the mesh as a side effect of the next refresh. They don't need a
# bespoke preview regenerator.
def _wall_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
regenerate_wall_mesh_from_props(obj)
def _stair_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.stair import regenerate_stair_mesh
regenerate_stair_mesh(obj)
def _roof_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.roof import update_roof_modifier_bmesh
update_roof_modifier_bmesh(obj)
UNDO_REGENERATORS: dict[str, Callable[[bpy.types.Object], None]] = {
"wall": _wall_undo_regenerator,
"stair": _stair_undo_regenerator,
"roof": _roof_undo_regenerator,
}
def resync_parametric_drafts_after_undo() -> None:
"""Re-render preview meshes for every parametric draft currently active.
Walks all objects, skips any not in a registered parametric edit,
dispatches to the per-type regenerator in ``UNDO_REGENERATORS``. A type
without an entry is left alone its preview is either already correct
(IFC-derived) or has no draft preview mesh."""
for obj in bpy.data.objects:
feature = tool.Parametric.is_object_editing(obj)
if feature is None:
continue
regenerator = UNDO_REGENERATORS.get(feature.name)
if regenerator is None:
continue
regenerator(obj)
tool.Blender.update_all_viewports()
@persistent
def _resync_on_undo(scene: bpy.types.Scene) -> None:
resync_parametric_drafts_after_undo()
def install_parametric_lifecycle_handlers() -> None:
"""Append the undo-resync callback to undo_post and redo_post; idempotent.
Caller must invoke this AFTER appending the central undo/redo handlers so
regenerators see restored IFC state bpy.app.handlers fire in append order."""
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
if _resync_on_undo not in hook:
hook.append(_resync_on_undo)
def uninstall_parametric_lifecycle_handlers() -> None:
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
try:
hook.remove(_resync_on_undo)
except ValueError:
pass
+31 -112
View File
@@ -15,8 +15,6 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import os
import platform
@@ -382,76 +380,6 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup):
cycle: bool
class GizmoPreferencesWall(bpy.types.PropertyGroup):
"""Property group for wall gizmo visibility settings."""
length: BoolProperty(
name="Length",
default=True,
description="Show the length dimension gizmo along the wall axis.",
)
height: BoolProperty(
name="Height",
default=True,
description="Show the height dimension gizmo at the wall's start endpoint.",
)
height_end: BoolProperty(
name="Height (far end, walls > 5m)",
default=True,
description=(
"Show a second height gizmo at the wall's far end so long walls don't "
"require panning to reach the handle."
),
)
x_angle: BoolProperty(
name="Slope",
default=True,
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
)
cycle: BoolProperty(
name="Cycle Offset Baseline",
default=True,
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
)
scissors: BoolProperty(
name="Split at cursor",
default=True,
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
)
extend: BoolProperty(
name="Extend length to cursor X",
default=True,
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
)
extend_height: BoolProperty(
name="Extend height to cursor Z",
default=True,
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
)
rotate: BoolProperty(
name="Rotate 90°",
default=True,
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
)
toggle_openings: BoolProperty(
name="Toggle Openings",
default=True,
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
)
if TYPE_CHECKING:
length: bool
height: bool
height_end: bool
x_angle: bool
cycle: bool
scissors: bool
extend: bool
extend_height: bool
rotate: bool
toggle_openings: bool
class GizmoPreferences(bpy.types.PropertyGroup):
"""Property group for all gizmo visibility settings."""
@@ -463,14 +391,12 @@ class GizmoPreferences(bpy.types.PropertyGroup):
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: GizmoPreferencesDoor
window: GizmoPreferencesWindow
stair: GizmoPreferencesStair
wall: GizmoPreferencesWall
class DocPreferences(bpy.types.PropertyGroup):
@@ -923,56 +849,49 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
def _draw_parametric_gizmo_parameters(
self,
layout: bpy.types.UILayout,
gizmo_pg: bpy.types.PropertyGroup,
dimension_gizmo_class: type,
special_gizmo_names: frozenset[str] = frozenset(),
) -> None:
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
like baseline cycle, scissors, rotate, )."""
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
try:
annotations = gizmo_pg.__annotations__
except AttributeError:
annotations = type(gizmo_pg).__annotations__
for prop in annotations:
if prop in visible_names:
layout.prop(gizmo_pg, prop)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.door import GizmoDoorEdition
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
)
door_gizmos = self.gizmos.door
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
gizmo_prop_names.update(("swing_arc", "flip_arc"))
try:
annotations = door_gizmos.__annotations__
except AttributeError:
annotations = type(door_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(door_gizmos, prop)
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.window import GizmoWindowEdition
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
window_gizmos = self.gizmos.window
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
try:
annotations = window_gizmos.__annotations__
except AttributeError:
annotations = type(window_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(window_gizmos, prop)
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.stair import GizmoStairEdition
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
)
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.wall import GizmoWallEdition
self._draw_parametric_gizmo_parameters(
layout,
self.gizmos.wall,
GizmoWallEdition,
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
)
stair_gizmos = self.gizmos.stair
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
try:
annotations = stair_gizmos.__annotations__
except AttributeError:
annotations = type(stair_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names or prop in special_gizmo_names:
layout.prop(stair_gizmos, prop)
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "occurrence_name_style")
-3
View File
@@ -93,8 +93,6 @@ def enter_aggregate_mode(
aggregator: type[tool.Aggregate],
obj: bpy.types.Object,
):
if not aggregator.get_aggregate_props().in_aggregate_mode:
aggregator.save_previous_selection()
aggregator.update_previous_aggregate_mode_state()
if aggregator.get_higher_aggregate():
aggregator.disable_aggregate_mode()
@@ -109,7 +107,6 @@ def exit_aggregate_mode(aggregator: type[tool.Aggregate]):
aggregator.enable_aggregate_mode(new_obj)
else:
aggregator.disable_aggregate_mode()
aggregator.restore_previous_selection()
class IncompatibleAggregateError(Exception):
+8 -514
View File
@@ -15,13 +15,10 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any, Literal, Optional
from typing import TYPE_CHECKING, Literal, Optional
if TYPE_CHECKING:
import bpy
@@ -34,24 +31,6 @@ if TYPE_CHECKING:
OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"]
# Arc sample count for fillet preview polylines. 24 samples produces a visually
# smooth arc at common viewport scales without bloating the GPU batch.
FILLET_DEFAULT_ARC_RESOLUTION = 24
# Dot-product floor for treating two wall-axis segments as parallel — below
# this the projected intersection is too sensitive to floating-point noise
# to be useful as a junction apex. Calibrated to ~2° from parallel.
PARALLEL_DOT_THRESHOLD = 0.9994
# Perpendicular distance (SI metres) under which two parallel wall axes are
# considered to share the same infinite line. Calibrated to absorb sub-50mm
# placement drift between authored-joined walls without merging genuinely
# offset parallel walls.
COLLINEAR_LINE_TOLERANCE = 0.05
# Default proximity (SI metres) for classifying a layer offset against the
# canonical EXTERIOR / CENTER / INTERIOR baselines. Tight enough that ordinary
# millimetre-scale modelling intent always falls into the nearest baseline.
BASELINE_OFFSET_TOLERANCE = 0.001
def unjoin_walls(
ifc: type[tool.Ifc],
blender: type[tool.Blender],
@@ -161,73 +140,23 @@ def align_objects(
model.align_objects(reference_obj, objs, align_type)
def regenerate_wall_to_underside(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
wall_objs: list[bpy.types.Object],
) -> None:
"""Re-clip walls to their connected underside objects after the slab has moved."""
clipped_objs = []
for obj in wall_objs:
wall = ifc.get_entity(obj)
slab_objs = model.get_connected_slab_objs(wall)
if not slab_objs:
continue
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
# Sync each slab's Blender mesh to its current IFC representation before
# reading face geometry, so a changed profile is picked up correctly.
model.reload_body_representation(slab_objs)
model.remove_wall_to_underside_booleans(wall)
for slab_obj in slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if clip:
model.clip_wall_to_slab(wall, clip)
clipped_objs.append(obj)
if clipped_objs:
model.reload_body_representation(clipped_objs)
def extend_wall_to_slab(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
slab_objs: list[bpy.types.Object],
slab_obj: bpy.types.Object,
wall_objs: list[bpy.types.Object],
) -> None:
# If any wall is currently in item mode, exit it before modifying the
# representation. Leaving stale item objects around causes delete_ifc_item
# to later remove the extrusion (or other pre-boolean items) from inside
# the boolean chain, corrupting the IFC model.
geom_props = geometry.get_geometry_props()
if geom_props.representation_obj in wall_objs:
geometry.disable_item_mode()
clipped_walls = []
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
return # Nothing to clip?
slab = ifc.get_entity(slab_obj)
for obj in wall_objs:
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
wall = ifc.get_entity(obj)
# Merge previously connected slabs with newly requested ones so that
# re-running the operator never produces duplicate booleans and never
# silently discards clips that were applied in an earlier call.
existing = model.get_connected_slab_objs(wall)
seen = {id(s) for s in existing}
all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen]
# Remove stale booleans once, then re-clip against the full set.
model.remove_wall_to_underside_booleans(wall)
did_clip = False
for slab_obj in all_slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if not clip:
continue
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
did_clip = True
if did_clip:
clipped_walls.append(obj)
if clipped_walls:
model.reload_body_representation(clipped_walls)
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, slab)
model.reload_body_representation(wall_objs)
class RequireTwoWallsError(Exception):
@@ -244,438 +173,3 @@ class RequireAtLeastTwoElements(Exception):
class RequireLayeredElement(Exception):
pass
# --- Wall geometry math (pure) ------------------------------------------------
# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``.
# Callers convert ``mathutils.Vector`` at the boundary.
def baseline_from_offset(offset: float, thickness: float, tolerance: float = BASELINE_OFFSET_TOLERANCE) -> str:
"""Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the
closest canonical baseline; falls back to ``"CENTER"`` when nothing is
within ``tolerance``."""
candidates = (
("EXTERIOR", 0.0),
("CENTER", -thickness / 2),
("INTERIOR", -thickness),
("EXTERIOR", thickness),
("CENTER", thickness / 2),
("INTERIOR", 0.0),
)
best = min(candidates, key=lambda c: abs(offset - c[1]))
return best[0] if abs(offset - best[1]) < tolerance else "CENTER"
def project_axis_intersection(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float,
) -> Optional[tuple[float, float, float]]:
"""Compute the 2D (X,Y plane) intersection of two world-space axis segments.
Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple
(Z is the average of the four input Zs, for visual placement) or ``None`` if
the segments are parallel within ``parallel_threshold`` (a dot-product magnitude
threshold see ``PARALLEL_DOT_THRESHOLD`` for the calibrated value)."""
p1, p2 = seg_a
p3, p4 = seg_b
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
d2x, d2y = p4[0] - p3[0], p4[1] - p3[1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return None
dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len)
if abs(dot) >= parallel_threshold:
return None
denom = d1x * d2y - d1y * d2x
if abs(denom) < 1e-9:
return None
t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom
ix = p1[0] + t * d1x
iy = p1[1] + t * d1y
iz = (p1[2] + p2[2] + p3[2] + p4[2]) / 4
return (ix, iy, iz)
def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool:
"""True when the opening's near edge sits past the cut on the t axis.
Strict inequality is load-bearing: a boundary touch or NaN keeps the
opening on both walls the safe default when extent resolution fails."""
return min_t > cut_percentage
def opening_is_before_cut(max_t: float, cut_percentage: float) -> bool:
"""True when the opening's far edge sits before the cut on the t axis."""
return max_t < cut_percentage
def opening_straddles_cut(min_t: float, max_t: float, cut_percentage: float) -> bool:
"""True when the opening's extent crosses the cut on the t axis."""
return min_t < cut_percentage < max_t
WallJoinState = Literal["joined", "collinear", "intersect", "none"]
def classify_wall_join_state(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
are_joined: bool,
parallel_threshold: float,
collinear_tolerance: float,
) -> tuple[WallJoinState, Optional[tuple[float, float, float]]]:
"""Classify a wall pair's geometric state — ``(state, intersection)``.
Priority: ``"joined"`` (caller-supplied flag) ``"collinear"``
``"intersect"`` (projected point returned) ``"none"`` (parallel,
non-collinear)."""
if are_joined:
return "joined", None
if are_axes_collinear(seg_a, seg_b, parallel_threshold, collinear_tolerance):
return "collinear", None
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
if intersection is None:
return "none", None
return "intersect", intersection
def wall_join_preview_lines(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
intersection: tuple[float, float, float],
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
"""Two segments showing each wall axis extending to ``intersection``.
Each segment runs from the input axis's nearest endpoint to the
intersection, held at that wall's own Z. Returned in input order
``[floor_a, floor_b]``."""
ix, iy, _ = intersection
def _nearest(seg: tuple[tuple[float, float, float], tuple[float, float, float]]) -> tuple[float, float, float]:
return min(seg, key=lambda p: (p[0] - ix) ** 2 + (p[1] - iy) ** 2)
near_a = _nearest(seg_a)
near_b = _nearest(seg_b)
return [
(near_a, (ix, iy, near_a[2])),
(near_b, (ix, iy, near_b[2])),
]
def resolve_extend_walls_target(
target_obj: Any,
objs: list[Any],
reverse: bool,
) -> tuple[Any, list[Any]]:
"""Pick which object is the extend-target and which are extended.
Default direction: ``objs`` are extended to meet ``target_obj``.
Reversed direction (``reverse=True``) swaps the pair equivalent to
having passed them in the opposite order. The swap is well-defined only
for the 1+1 case (one target + one other); for ``n>1`` it would be
ambiguous, so the default direction is preserved instead."""
if reverse and target_obj is not None and len(objs) == 1:
return objs[0], [target_obj]
return target_obj, objs
def displacement_from_x_angle(height: float, x_angle: float) -> float:
"""Top-edge horizontal displacement for a wall of given vertical ``height``
and slope ``x_angle`` (radians). Inverse of ``x_angle_from_displacement``."""
return height * math.tan(x_angle)
def x_angle_from_displacement(height: float, displacement: float) -> float:
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map
cleanly to ``±π/2`` instead of dividing by zero."""
return math.atan2(displacement, max(height, 1e-6))
def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) -> float:
"""Vertical height of a wall given its slanted extrusion depth and slope.
``IfcExtrudedAreaSolid.Depth`` measures along the (possibly slanted) extrusion
direction. The vertical height the user thinks of is ``depth * cos(x_angle)``.
Unit-agnostic: the result is in the same units as ``extrusion_depth``."""
return extrusion_depth * abs(math.cos(x_angle))
def extrusion_depth_from_vertical_height(vertical_height: float, x_angle: float) -> float:
"""``vertical_height / cos(x_angle)`` with ``cos`` clamped at ``1e-6`` to
stay finite near ``±π/2``."""
return vertical_height / max(abs(math.cos(x_angle)), 1e-6)
def length_and_height_from_extrusion(
extrusion_depth: float,
x_angle: float,
reference_line_x_extent: float,
unit_scale: float,
) -> tuple[float, float]:
"""SI ``(length, vertical_height)`` of a LAYER2 wall.
Height is the *vertical* projection of the slanted depth, not the
slanted depth itself."""
length = reference_line_x_extent * unit_scale
height = vertical_height_from_extrusion_depth(extrusion_depth * unit_scale, x_angle)
return length, height
def are_axes_collinear(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
line_tolerance: float = COLLINEAR_LINE_TOLERANCE,
) -> bool:
"""True if both axis segments lie on the same infinite line in plan.
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``,
AND any endpoint of B must lie on A's infinite line within ``line_tolerance``.
Plan-only (Z ignored)."""
d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1]
d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return False
if abs((d1x * d2x + d1y * d2y) / (d1_len * d2_len)) < parallel_threshold:
return False
# Project seg_b[0] onto the infinite line through seg_a; the perpendicular
# distance to the original point tells us how far off the line B sits.
nx, ny = d1x / d1_len, d1y / d1_len
dx, dy = seg_b[0][0] - seg_a[0][0], seg_b[0][1] - seg_a[0][1]
t = dx * nx + dy * ny
proj_x = seg_a[0][0] + nx * t
proj_y = seg_a[0][1] + ny * t
perp_x = seg_b[0][0] - proj_x
perp_y = seg_b[0][1] - proj_y
return (perp_x * perp_x + perp_y * perp_y) ** 0.5 < line_tolerance
def closest_endpoint_midpoint(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
) -> tuple[float, float, float]:
"""Midpoint of the closest endpoint pair between two segments."""
endpoints_a = (seg_a[0], seg_a[1])
endpoints_b = (seg_b[0], seg_b[1])
def _distance_sq(p: tuple[float, float, float], q: tuple[float, float, float]) -> float:
return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2
closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair))
a, b = closest_pair
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
def compute_path_connection_location(
seg_self: tuple[tuple[float, float, float], tuple[float, float, float]],
self_conn_type: str,
seg_other: tuple[tuple[float, float, float], tuple[float, float, float]],
other_conn_type: str,
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
) -> tuple[float, float, float]:
"""World-space location of a single ``IfcRelConnectsPathElements`` between
two wall axes.
Priority: ``self``'s ATSTART/ATEND endpoint → ``other``'s ATSTART/ATEND
endpoint axis intersection closest-endpoint midpoint fallback."""
if self_conn_type == "ATSTART":
return seg_self[0]
if self_conn_type == "ATEND":
return seg_self[1]
if other_conn_type == "ATSTART":
return seg_other[0]
if other_conn_type == "ATEND":
return seg_other[1]
intersection = project_axis_intersection(seg_self, seg_other, parallel_threshold)
if intersection is not None:
return intersection
return closest_endpoint_midpoint(seg_self, seg_other)
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
def _vec_length(v: tuple[float, float, float]) -> float:
return (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) ** 0.5
def _rotate_around_axis(
v: tuple[float, float, float],
axis: tuple[float, float, float],
angle: float,
) -> tuple[float, float, float]:
"""Rotate ``v`` around unit-length ``axis`` by ``angle`` radians."""
cos_a = math.cos(angle)
sin_a = math.sin(angle)
dot = _vec_dot(axis, v)
cross = _vec_cross(axis, v)
k = 1.0 - cos_a
return (
v[0] * cos_a + cross[0] * sin_a + axis[0] * dot * k,
v[1] * cos_a + cross[1] * sin_a + axis[1] * dot * k,
v[2] * cos_a + cross[2] * sin_a + axis[2] * dot * k,
)
def compute_fillet_polylines(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
radius: float,
arc_resolution: int = FILLET_DEFAULT_ARC_RESOLUTION,
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
) -> dict:
"""Preview polylines for a circular fillet at the junction of two axes.
Returns a dict with ``valid``, ``reason``, ``intersection``, ``tangent_a``
/ ``tangent_b``, ``arc`` (``arc_resolution + 1`` samples), ``arc_center``,
``arc_radius``, ``sweep_angle``, ``sweep_axis``, ``tangent_offset``,
``wall_a_join_side`` / ``wall_b_join_side`` (ATSTART/ATEND/None),
``invalid_radius`` (tangent overshoots arc + tangents still populated
for warning rendering), and ``invalid_axes`` (set on parallel)."""
blank: dict = {
"valid": False,
"reason": None,
"intersection": None,
"tangent_a": None,
"tangent_b": None,
"arc": [],
"arc_center": None,
"arc_radius": radius,
"sweep_angle": 0.0,
"sweep_axis": None,
"tangent_offset": 0.0,
"wall_a_join_side": None,
"wall_b_join_side": None,
"invalid_radius": False,
"invalid_axes": None,
}
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
if intersection is None:
return {**blank, "reason": "parallel", "invalid_axes": [seg_a, seg_b]}
def _classify(seg, ipt):
d0 = (seg[0][0] - ipt[0]) ** 2 + (seg[0][1] - ipt[1]) ** 2 + (seg[0][2] - ipt[2]) ** 2
d1 = (seg[1][0] - ipt[0]) ** 2 + (seg[1][1] - ipt[1]) ** 2 + (seg[1][2] - ipt[2]) ** 2
if d0 <= d1:
return seg[0], seg[1], "ATSTART"
return seg[1], seg[0], "ATEND"
near_a, far_a, side_a = _classify(seg_a, intersection)
near_b, far_b, side_b = _classify(seg_b, intersection)
# Direction along each segment AWAY from the corner. ``far - intersection``
# handles both the shared-corner and extended-axes cases uniformly.
dir_a_raw = _vec_sub(far_a, intersection)
dir_b_raw = _vec_sub(far_b, intersection)
far_len_a = _vec_length(dir_a_raw)
far_len_b = _vec_length(dir_b_raw)
if far_len_a < 1e-9 or far_len_b < 1e-9:
return {**blank, "reason": "near_collinear", "intersection": intersection}
dir_a = (dir_a_raw[0] / far_len_a, dir_a_raw[1] / far_len_a, dir_a_raw[2] / far_len_a)
dir_b = (dir_b_raw[0] / far_len_b, dir_b_raw[1] / far_len_b, dir_b_raw[2] / far_len_b)
cos_angle = max(-1.0, min(1.0, _vec_dot(dir_a, dir_b)))
angle = math.acos(cos_angle)
sweep_angle = math.pi - angle
if sweep_angle < 1e-3 or sweep_angle > math.pi - 1e-3:
return {
**blank,
"reason": "near_collinear",
"intersection": intersection,
"sweep_angle": sweep_angle,
"wall_a_join_side": side_a,
"wall_b_join_side": side_b,
}
tangent_offset = radius * math.tan(sweep_angle / 2)
tangent_a = (
intersection[0] + dir_a[0] * tangent_offset,
intersection[1] + dir_a[1] * tangent_offset,
intersection[2] + dir_a[2] * tangent_offset,
)
tangent_b = (
intersection[0] + dir_b[0] * tangent_offset,
intersection[1] + dir_b[1] * tangent_offset,
intersection[2] + dir_b[2] * tangent_offset,
)
plane_normal_raw = _vec_cross(dir_a, dir_b)
pn_len = _vec_length(plane_normal_raw)
if pn_len < 1e-9:
return {**blank, "reason": "near_collinear", "intersection": intersection}
plane_normal = (
plane_normal_raw[0] / pn_len,
plane_normal_raw[1] / pn_len,
plane_normal_raw[2] / pn_len,
)
perp_a = _vec_cross(plane_normal, dir_a)
if _vec_dot(perp_a, dir_b) < 0:
perp_a = (-perp_a[0], -perp_a[1], -perp_a[2])
arc_center = (
tangent_a[0] + perp_a[0] * radius,
tangent_a[1] + perp_a[1] * radius,
tangent_a[2] + perp_a[2] * radius,
)
v_a = _vec_sub(tangent_a, arc_center)
v_b = _vec_sub(tangent_b, arc_center)
sweep_axis = plane_normal
if _vec_dot(_vec_cross(v_a, v_b), plane_normal) < 0:
sweep_axis = (-plane_normal[0], -plane_normal[1], -plane_normal[2])
arc_points: list[tuple[float, float, float]] = []
for i in range(arc_resolution + 1):
t = i / arc_resolution
rotated = _rotate_around_axis(v_a, sweep_axis, sweep_angle * t)
arc_points.append(
(
arc_center[0] + rotated[0],
arc_center[1] + rotated[1],
arc_center[2] + rotated[2],
)
)
# Overshoot check only for convex fillets (positive ``tangent_offset``);
# the inverted-fillet case puts tangents past the intersection.
invalid_radius = tangent_offset > 0 and (tangent_offset > far_len_a or tangent_offset > far_len_b)
return {
"valid": not invalid_radius,
"reason": "invalid_radius" if invalid_radius else None,
"intersection": intersection,
"tangent_a": tangent_a,
"tangent_b": tangent_b,
"arc": arc_points,
"arc_center": arc_center,
"arc_radius": radius,
"sweep_angle": sweep_angle,
"sweep_axis": sweep_axis,
"tangent_offset": tangent_offset,
"wall_a_join_side": side_a,
"wall_b_join_side": side_b,
"leg_a_available": far_len_a,
"leg_b_available": far_len_b,
"invalid_radius": invalid_radius,
"invalid_axes": None,
}
-64
View File
@@ -1,64 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import math
from collections.abc import Iterable
from typing import TYPE_CHECKING
import bonsai.core.geometry
if TYPE_CHECKING:
import bpy
import bonsai.tool as tool
Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9
def _z_rotation_diff(target_z: float, source_z: float) -> float:
"""Signed Z-Euler difference wrapped to [-π, π]."""
return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi
def copy_z_rotation_to_selected(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
surveyor: type[tool.Surveyor],
*,
active: bpy.types.Object,
targets: Iterable[bpy.types.Object],
flip: bool = False,
) -> int:
"""Apply ``active``'s Z-Euler rotation to each target."""
source_z = surveyor.get_z_rotation(active)
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z)
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
return rotated
+1 -2
View File
@@ -67,8 +67,7 @@ def assign_container(
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
ifc.run("spatial.assign_container", products=products, relating_structure=container)
for element in all_elements:
if obj := ifc.get_object(element):
collector.assign(obj)
collector.assign(ifc.get_object(element))
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
+2 -63
View File
@@ -415,17 +415,6 @@ class Drawing:
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
@interface
class Duplicate:
def get_decomposition_relationships(cls, objs): pass
def get_connection_relationships(cls, objs): pass
def get_port_connection_relationships(cls, objs): pass
def recreate_decompositions(cls, relationships, old_to_new): pass
def recreate_connections(cls, relationship, old_to_new): pass
def recreate_port_connections(cls, snapshot, old_to_new): pass
def consume_warnings(cls): pass
@interface
class Feature:
def add_feature(cls, featured_obj, featured_objs): pass
@@ -456,10 +445,8 @@ class Geometry:
def get_representation_name(cls, representation): pass
def get_styles(cls, obj): pass
def get_total_representation_items(cls, obj): pass
def has_axis_representation(cls, element): pass
def has_data_users(cls, data): pass
def has_material_style_override(cls, obj): pass
def has_material_styles(cls, element): pass
def import_representation_parameters(cls, data): pass
def is_body_representation(cls, representation): pass
def is_box_representation(cls, representation): pass
@@ -681,9 +668,6 @@ class Model:
def export_profile(cls, obj, position=None): pass
def generate_occurrence_name(cls, element_type, ifc_class): pass
def get_extrusion(cls, representation): pass
def get_connected_slab_objs(cls, wall): pass
def get_connected_wall_objs(cls, slab): pass
def has_underside_connection(cls, element): pass
def get_manual_booleans(cls, element): pass
def get_material_layer_parameters(cls, element): pass
def get_slab_clipping_bmesh(cls, obj): pass
@@ -699,7 +683,6 @@ class Model:
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def remove_wall_to_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -793,12 +776,6 @@ class Profile:
def get_profile(cls, element): pass
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls) -> None: pass
@interface
class Pset:
def add_proposed_property(cls, name, value, props): pass
@@ -882,6 +859,7 @@ class Root:
def assign_body_styles(cls, element, obj): pass
def copy_representation(cls, source, dest): pass
def does_type_have_representations(cls, element): pass
def get_decomposition_relationships(cls, objs): pass
def get_default_container(cls): pass
def get_element_representation(cls, element, context): pass
def get_element_type(cls, element): pass
@@ -895,6 +873,7 @@ class Root:
def is_in_nest_mode(cls, element): pass
def is_spatial_element(cls, element): pass
def link_object_data(cls, source_obj, destination_obj): pass
def recreate_decompositions(cls, relationships, old_to_new): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
def set_object_name(cls, obj, element): pass
@@ -1038,8 +1017,6 @@ class Spatial:
def get_container(cls, element): pass
def get_decomposed_elements(cls, container, recursive): pass
def get_decomposition(cls, element): pass
def get_host_element(cls, filling): pass
def get_host_wall(cls, filling): pass
def get_object_matrix(cls, obj): pass
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
def get_root_element(cls, element): pass
@@ -1160,8 +1137,6 @@ class Style:
@interface
class Surveyor:
def get_absolute_matrix(cls, obj): pass
def get_z_rotation(cls, obj): pass
def set_z_rotation(cls, obj, z): pass
@interface
@@ -1228,42 +1203,6 @@ class Voider:
def void(cls, opening_obj, building_obj): pass
@interface
class Array:
def bake_children_transform(cls, parent_element, item): pass
def constrain_children_to_parent(cls, parent_element): pass
def get_all_children_objects(cls, parent_element): pass
def get_all_objects(cls, parent_element): pass
def get_child_layer_index(cls, child_element): pass
def get_children_objects(cls, modifier_data): pass
def get_modifiers_data(cls, parent_element): pass
def get_parent_element(cls, element): pass
def get_parent_object(cls, element): pass
def remove_constraints(cls, parent_element): pass
def set_children_lock_state(cls, parent_element, item, lock_state): pass
@interface
class Slab:
def read_geometry(cls, obj): pass
@interface
class Wall:
def collinear_boundary_world(cls, seg_a, seg_b): pass
def compute_wall_fillet_geometry(cls, wall_a_obj, wall_b_obj, radius, arc_resolution): pass
def get_axis_local_extent(cls, wall): pass
def get_length_and_height(cls, wall): pass
def get_world_reference_line(cls, obj): pass
def get_x_angle(cls, wall): pass
def has_layer2_usage(cls, wall): pass
def is_straight_axis(cls, wall): pass
def path_connection_location_world(cls, seg_self, self_conn_type, seg_other, other_conn_type, parallel_threshold): pass
def read_geometry(cls, obj): pass
def validate_for_parametric_edit(cls, obj): pass
def walk_connected_walls(cls, start_element, node_cap): pass
@interface
class Web:
pass
-5
View File
@@ -20,7 +20,6 @@
# ruff: noqa: F401
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.array import Array
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
@@ -38,7 +37,6 @@ from bonsai.tool.debug import Debug
from bonsai.tool.demo import Demo
from bonsai.tool.document import Document
from bonsai.tool.drawing import Drawing
from bonsai.tool.duplicate import Duplicate
from bonsai.tool.feature import Feature
from bonsai.tool.geometry import Geometry
from bonsai.tool.georeference import Georeference
@@ -53,7 +51,6 @@ from bonsai.tool.misc import Misc
from bonsai.tool.model import Model
from bonsai.tool.nest import Nest
from bonsai.tool.owner import Owner
from bonsai.tool.parametric import Parametric
from bonsai.tool.patch import Patch
from bonsai.tool.polyline import Polyline
from bonsai.tool.profile import Profile
@@ -66,7 +63,6 @@ from bonsai.tool.resource import Resource
from bonsai.tool.root import Root
from bonsai.tool.search import Search
from bonsai.tool.sequence import Sequence
from bonsai.tool.slab import Slab
from bonsai.tool.snap import Snap
from bonsai.tool.spatial import Spatial
from bonsai.tool.structural import Structural
@@ -76,5 +72,4 @@ from bonsai.tool.system import System
from bonsai.tool.tester import Tester
from bonsai.tool.type import Type
from bonsai.tool.unit import Unit
from bonsai.tool.wall import Wall
from bonsai.tool.web import Web
-21
View File
@@ -205,27 +205,6 @@ class Aggregate(bonsai.core.tool.Aggregate):
props.in_aggregate_mode = True
return {"FINISHED"}
@classmethod
def save_previous_selection(cls) -> None:
props = cls.get_aggregate_props()
props.previously_selected_objects.clear()
for obj in bpy.context.selected_objects:
entry = props.previously_selected_objects.add()
entry.obj = obj
@classmethod
def restore_previous_selection(cls) -> None:
props = cls.get_aggregate_props()
for obj in bpy.context.selected_objects:
obj.select_set(False)
for entry in props.previously_selected_objects:
if entry.obj:
try:
entry.obj.select_set(True)
except Exception:
pass
props.previously_selected_objects.clear()
@classmethod
def disable_aggregate_mode(cls):
context = bpy.context
-207
View File
@@ -1,207 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Bonsai parametric array service.
Top-level array-domain helpers. The ``BBIM_Array`` pset on a parent ``IfcElement``
holds the list of layers; each layer holds the GUIDs of its child replicas. These
helpers navigate that graph and manage the Blender-side CHILD_OF constraint that
pins children to the parent's matrix_world."""
from __future__ import annotations
import json
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.element
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
class Array(bonsai.core.tool.Array):
@classmethod
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
with bpy.context.temp_override(object=child):
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
if not (parent_obj := tool.Ifc.get_object(parent_element)):
return # Filtered out, arrayed void, etc
assert isinstance(parent_obj, bpy.types.Object)
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
constraint = child.constraints.new("CHILD_OF")
constraint.name = "BBIM_Array_CHILD_OF"
assert isinstance(constraint, bpy.types.ChildOfConstraint)
constraint.target = parent_obj
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child_obj in children:
tool.Blender.lock_transform(child_obj, lock_state)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
parent_obj = tool.Ifc.get_object(parent_element)
assert isinstance(parent_obj, bpy.types.Object)
children_objects = list(cls.get_all_children_objects(parent_element))
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
return array_objects
@classmethod
def get_all_children_objects(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[bpy.types.Object, None, None]:
for array_modifier in cls.get_modifiers_data(parent_element):
yield from cls.get_children_objects(array_modifier)
@classmethod
def get_parent_element(cls, element: entity_instance) -> entity_instance | None:
"""Inverse of ``get_all_children_objects``: resolve an array element
back to its parent entity. Returns ``None`` when the element isn't
part of a Bonsai parametric array, or the stored Parent GUID does
not resolve in the current file (this is a data-integrity warning
and is logged to the console)."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return None
parent_guid = pset["Parent"]
try:
return tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
print(
f"BBIM_Array.Parent GUID {parent_guid!r} on {element} does not resolve "
f"in the current file — array integrity may be broken."
)
return None
@classmethod
def get_parent_object(cls, element: entity_instance) -> bpy.types.Object | None:
parent_element = cls.get_parent_element(element)
if parent_element is None:
return None
return tool.Ifc.get_object(parent_element)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance) -> Generator[dict[str, Any], None, None]:
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
yield from json.loads(array_pset["Data"])
@classmethod
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
child_guid: str
for child_guid in modifier_data["children"]:
child_obj = tool.Blender.get_object_from_guid(child_guid)
if child_obj:
yield child_obj
@classmethod
def get_array_root_guid(cls, element: entity_instance) -> str:
"""Walk ``BBIM_Array.Parent`` upwards and return the topmost ancestor's
GlobalId. For an element with no ``BBIM_Array`` pset (independent
window, never arrayed, or former-child after the apply path), returns
the element's own GlobalId — its "family" is just itself."""
current = element
seen: set[str] = set()
while True:
pset = ifcopenshell.util.element.get_pset(current, "BBIM_Array")
parent_guid = pset.get("Parent") if pset else None
if not parent_guid or parent_guid == current.GlobalId or parent_guid in seen:
return current.GlobalId
seen.add(parent_guid)
try:
current = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return current.GlobalId
@classmethod
def get_parametric_propagation_targets(cls, element: entity_instance) -> list[entity_instance]:
"""Type-occurrences that should receive parametric updates when
``element`` is edited.
Returns occurrences in ``element``'s Bonsai array family. When
``element`` is not part of any array, returns the type-occurrence
peers that are likewise free of ``BBIM_Array`` (preserving the
bulk-edit-by-type UX for standalone parametric elements). An
occurrence whose ``BBIM_Array`` root differs from ``element``'s root
is excluded that is the "independent former child" case the array
apply path produces."""
occurrences = tool.Ifc.get_all_element_occurrences(element)
element_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not element_pset:
return [o for o in occurrences if not ifcopenshell.util.element.get_pset(o, "BBIM_Array")]
element_root = cls.get_array_root_guid(element)
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
@classmethod
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
"""Index of the layer that produced ``child_element``, or ``None``
if the child is unparented, missing from the parent's data, or the
parent's pset is unreadable. Total: never raises."""
pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
if not pset:
return None
parent_guid = pset.get("Parent")
if not parent_guid or parent_guid == child_element.GlobalId:
return None
try:
parent_element = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return None
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
if not data_text:
return None
try:
layers = json.loads(data_text)
except (ValueError, TypeError):
return None
child_guid = child_element.GlobalId
for i, layer in enumerate(layers):
if child_guid in layer.get("children", []):
return i
return None
+149 -332
View File
@@ -15,13 +15,12 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
import contextlib
import importlib
import json
import os
import platform
import subprocess
@@ -29,7 +28,7 @@ import sys
import tempfile
import traceback
import types
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
from collections.abc import Callable, Generator, Iterable, Sequence, Sized
from datetime import datetime
from functools import cache, lru_cache
from pathlib import Path
@@ -46,6 +45,7 @@ from typing import (
import bmesh
import bpy
import ifcopenshell.api
import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
@@ -55,12 +55,12 @@ from mathutils import Matrix, Vector
import bonsai.bim
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
from sun_position.properties import SunPosProperties
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
from bonsai.bim.module.attribute.prop import BIMAttributeProperties
from bonsai.bim.module.constraint.prop import (
BIMConstraintProperties,
@@ -97,19 +97,6 @@ VIEWPORT_ATTRIBUTES = [
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera]
_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType")
_STAIR_MODIFIER_IFC_CLASSES = (
"IfcStairFlight",
"IfcStairFlightType",
"IfcMember",
"IfcMemberType",
"IfcStair",
"IfcStairType",
)
_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle")
_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle")
_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType")
class Blender(bonsai.core.tool.Blender):
OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE")
@@ -428,189 +415,6 @@ class Blender(bonsai.core.tool.Blender):
with bpy.context.temp_override(**cls.get_viewport_context()):
bpy.ops.wm.tool_set_by_id(name=tool_name)
@classmethod
def are_viewport_gizmos_enabled(cls) -> bool:
"""Central gate every Bonsai gizmo poll / decorator draw checks before
rendering. Centralises the read of
``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences."""
return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport
class DecoratorColors(NamedTuple):
selected: tuple
unselected: tuple
special: tuple
error: tuple
background: tuple
@classmethod
def get_decorator_colors(cls) -> Blender.DecoratorColors:
"""The five ``decorator_color_*`` fields read together so each viewport
decorator's draw callback resolves them in one call instead of five."""
prefs = cls.get_addon_preferences()
return cls.DecoratorColors(
selected=prefs.decorator_color_selected,
unselected=prefs.decorator_color_unselected,
special=prefs.decorator_color_special,
error=prefs.decorator_color_error,
background=prefs.decorator_color_background,
)
class ViewportDecorator:
"""Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators.
Single-handler subclasses set ``draw_method`` (default ``"draw"``); the
handler binds at ``POST_VIEW``. Multi-handler subclasses set
``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it
is non-``None`` it supersedes ``draw_method``.
Decorators whose ``install`` must accept extra arguments (e.g. a callback
or a precomputed bmesh) override ``install`` themselves."""
draw_method: str = "draw"
draw_methods: tuple[tuple[str, str], ...] | None = None
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.handlers = []
cls.is_installed = False
# Fail loudly at class-definition time if draw_method / draw_methods
# names an attribute the class doesn't expose. Without this, a typo
# only surfaces on the first redraw — as a silent missing-attribute
# handler — which may be far from the offending declaration.
method_names = (
tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,)
)
for name in method_names:
if getattr(cls, name, None) is None:
raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class")
@classmethod
def install(cls, context: bpy.types.Context) -> None:
if cls.is_installed:
cls.uninstall()
handler = cls()
bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),)
# Rollback partial registrations on any draw_handler_add failure, so
# cls.handlers never ends up holding a half-installed set.
added: list = []
try:
for method_name, phase in bindings:
added.append(
bpy.types.SpaceView3D.draw_handler_add(
getattr(handler, method_name), (context,), "WINDOW", phase
)
)
except Exception:
for h in added:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
raise
cls.handlers = added
cls.is_installed = True
@classmethod
def uninstall(cls) -> None:
for h in cls.handlers:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
cls.handlers.clear()
cls.is_installed = False
@staticmethod
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
"""Return the live ``GizmoGroup`` instance registered under
``context.region``, or ``None`` if there isn't one. The per-region
weakref dict on the gizmo class is populated by ``setup()``; multi-
viewport setups put one entry per region in it so each region's
decorator sees only its own region's hover state."""
instances = getattr(gizmo_cls, "_active_instances", None)
if not instances:
return None
region = getattr(context, "region", None)
if region is None:
return None
ref = instances.get(region.as_pointer())
if ref is None:
return None
return ref()
def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool:
"""True iff the gizmo group instance in the current region exposes a gizmo
under ``attr_name`` that reports as highlighted. Any access exception is
swallowed so a transient bpy-state hiccup never breaks the draw loop."""
inst = self._lookup_active_instance(gizmo_cls, context)
if inst is None:
return False
try:
return bool(getattr(inst, attr_name).is_highlight)
except (AttributeError, ReferenceError):
return False
@classmethod
def sync_all(
cls,
context: bpy.types.Context,
enabled: Mapping[type[Blender.ViewportDecorator], bool],
) -> None:
"""Drive each listed decorator to its desired install state in one call.
Each entry whose value is ``True`` ends up installed; each entry whose
value is ``False`` ends up uninstalled. Pass ``True`` for always-on
overlays so they survive subsequent file loads."""
for decorator_cls, should_install in enabled.items():
if should_install:
decorator_cls.install(context)
else:
decorator_cls.uninstall()
@classmethod
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
Default threshold of 0.9659 = cos(15°) a 15° tilt cone around ±world Z.
Above the threshold the world-Z axis projects to a small fraction of its
true length on screen, so callers that lay icons or markers out along
world Z should switch to a screen-space offset and any gizmo whose intent
is specifically "vertical" loses its visual cue. The cone is kept narrow
so vertical-intent gizmos stay visible across the typical orbit range of
3D viewport work and drop out only near genuine plan view."""
rv3d = context.region_data
if rv3d is None:
return False
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
return abs(view_forward.z) > threshold
@classmethod
def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float:
"""Continuous 01 ramp matching ``is_view_top_down``'s cone: 0 outside the
cone, ramping linearly to 1 at strict alignment with world Z. Callers that
want a proportional effect (an icon-stack lift growing as the view
approaches plan) use this in place of the boolean to avoid a one-frame
visual jump as the camera crosses the threshold."""
rv3d = context.region_data
if rv3d is None:
return 0.0
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
alignment = abs(view_forward.z)
if alignment <= threshold:
return 0.0
return (alignment - threshold) / (1.0 - threshold)
@classmethod
def get_screen_up_world(cls, context: bpy.types.Context) -> Vector:
"""World-space direction corresponding to the camera's up axis (screen-vertical).
Returns ``+Y`` when region data is unavailable so callers can compute an
offset without a guard branch."""
rv3d = context.region_data
if rv3d is None:
return Vector((0.0, 1.0, 0.0))
return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized()
@classmethod
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
for screen in bpy.data.screens:
@@ -680,13 +484,9 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None:
"""Tag every visible 3D viewport for redraw. Silent no-op when no
screen attached (background mode, plug-out, mid-load_post)."""
context = context or bpy.context
screen = getattr(context, "screen", None)
if screen is None:
return
for area in screen.areas:
assert context.screen
for area in context.screen.areas:
if area.type == "VIEW_3D":
area.tag_redraw()
@@ -835,11 +635,10 @@ class Blender(bonsai.core.tool.Blender):
op_text = "" if ui_context == "TOOL_HEADER" else text
modifier_icon, modifier_str = cls.KEY_MODIFIERS.get(modifier, ("NONE", ""))
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
module = sys.modules[module_name]
icon_previews: Union[bpy.utils.previews.ImagePreviewCollection, None]
icon_previews = getattr(module, "custom_icon_previews", None)
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
if icon_previews:
custom_icon = icon_previews.get(text.upper().replace(" ", "_"), icon_previews["IFC"]).icon_id
op = row.operator(operator_to_use, text=op_text, icon_value=custom_icon)
@@ -847,7 +646,6 @@ class Blender(bonsai.core.tool.Blender):
op = row.operator(operator_to_use, text=op_text)
if ui_context != "TOOL_HEADER":
row.label(text="", icon=modifier_icon)
row.separator(factor=1)
row.label(text="", icon=f"EVENT_{key}")
if operator_to_use == hotkey_operator:
@@ -1332,74 +1130,6 @@ class Blender(bonsai.core.tool.Blender):
return True
class Modifier:
# ----------------------------------------------------------------------
# FIXME(PR5): backward-compat shims for callers still using the
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
# this whole shim block is removed in PR5's cleanup.
# ----------------------------------------------------------------------
@classmethod
def is_door(cls, element: entity_instance) -> bool:
return tool.Parametric.is_door(element)
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
return tool.Parametric.is_railing(element)
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
return tool.Parametric.is_roof(element)
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
return tool.Parametric.is_stair(element)
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
return tool.Parametric.is_wall(element)
@classmethod
def is_window(cls, element: entity_instance) -> bool:
return tool.Parametric.is_window(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
tool.Array.bake_children_transform(parent_element, item)
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.constrain_children_to_parent(parent_element)
@classmethod
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_children_objects(parent_element)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_objects(parent_element)
@classmethod
def get_children_objects(cls, modifier_data: dict) -> list:
return tool.Array.get_children_objects(modifier_data)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
return tool.Array.get_modifiers_data(parent_element)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.remove_constraints(parent_element)
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
) -> None:
tool.Array.set_children_lock_state(parent_element, item, lock)
# ----------------------------------------------------------------------
@classmethod
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
"""Tries to validate the current BIM modifier parameters for the active object
@@ -1407,18 +1137,20 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
# roof and railing both finalize then drop into path-edit mode — handle
# them before the generic finish dispatch so the path transition runs.
if tool.Parametric.is_roof(element):
if tool.Parametric.ROOF.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op)
if cls.is_roof(element):
if cls.is_editing_roof_parameters(obj):
bpy.ops.bim.finish_editing_roof()
bpy.ops.bim.enable_editing_roof_path()
elif tool.Parametric.is_railing(element):
if tool.Parametric.RAILING.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
elif cls.is_railing(element):
if cls.is_editing_railing_parameters(obj):
bpy.ops.bim.finish_editing_railing()
bpy.ops.bim.enable_editing_railing_path()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.finish_editing_stair()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.finish_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.finish_editing_window()
else:
return False
return True
@@ -1429,80 +1161,68 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
# Path-edit modes are distinct from parametric draft modes; handle them first.
if cls.is_editing_railing_path(obj):
bpy.ops.bim.cancel_editing_railing_path()
elif cls.is_editing_roof_path(obj):
bpy.ops.bim.cancel_editing_roof_path()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.cancel_op)
elif cls.is_editing_railing_parameters(obj):
bpy.ops.bim.cancel_editing_railing()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.cancel_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.cancel_editing_window()
elif cls.is_editing_roof_parameters(obj):
bpy.ops.bim.cancel_editing_roof()
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.cancel_editing_stair()
else:
return False
return True
@classmethod
def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES)
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRailing", "IfcRailingType"))
@classmethod
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
return tool.Blender.is_object_an_ifc_class(
obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType")
)
@classmethod
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES)
return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle"))
@classmethod
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES)
return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle"))
@classmethod
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES)
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType"))
@classmethod
def is_array_child(cls, element: entity_instance) -> bool:
"""True if element is a CHILD of a Bonsai parametric array.
Children are managed replicas regenerated from the parent's pset —
their parametric attributes (door dimensions, wall lengths, ) are
overwritten on the next ``regenerate_array``. Parametric gizmo
groups skip children via this predicate in ``poll``.
This sits on a different axis from ``tool.Parametric.is_array``:
cardinality (parent vs child) is orthogonal to feature kind, and
an arrayed wall fires both ``is_wall`` and ``is_array`` on the
same element."""
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
parent_guid = pset.get("Parent")
return parent_guid is not None and parent_guid != element.GlobalId
def is_railing(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Railing")
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
"""A slab is host-eligible for the parametric add-opening gizmo if
it is an IfcSlab with LAYER3 usage.
Slabs carry no proprietary BBIM_Slab pset their parametric state
lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage
with LayerSetDirection AXIS3). Any LAYER3 slab qualifies."""
if element is None or not element.is_a("IfcSlab"):
return False
return tool.Model.get_usage_type(element) == "LAYER3"
def is_roof(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Roof")
@classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
def is_window(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Window")
@classmethod
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
def is_door(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Door")
@classmethod
def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool:
def is_stair(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Stair")
@classmethod
def is_editing_railing_path(cls, obj: bpy.types.Object):
props = tool.Model.get_railing_props(obj)
return props.is_editing_path
@@ -1511,10 +1231,107 @@ class Blender(bonsai.core.tool.Blender):
props = tool.Model.get_roof_props(obj)
return props.is_editing_path
@classmethod
def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_railing_props(obj)
return props.is_editing
@classmethod
def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_roof_props(obj)
return props.is_editing
@classmethod
def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_window_props(obj)
return props.is_editing
@classmethod
def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_door_props(obj)
return props.is_editing
@classmethod
def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_stair_props(obj)
return props.is_editing
@classmethod
def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool:
feature = tool.Parametric.find_for_element(element)
return bool(feature and feature.has_non_editable_path)
return cls.is_stair(element) or cls.is_door(element) or cls.is_window(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
with bpy.context.temp_override(object=child):
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
if not (parent_obj := tool.Ifc.get_object(parent_element)):
return # Filtered out, arrayed void, etc
assert isinstance(parent_obj, bpy.types.Object)
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
constraint = child.constraints.new("CHILD_OF")
constraint.name = "BBIM_Array_CHILD_OF"
assert isinstance(constraint, bpy.types.ChildOfConstraint)
constraint.target = parent_obj
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child_obj in children:
Blender.lock_transform(child_obj, lock_state)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
parent_obj = tool.Ifc.get_object(parent_element)
assert isinstance(parent_obj, bpy.types.Object)
children_objects = list(cls.get_all_children_objects(parent_element))
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
return array_objects
@classmethod
def get_all_children_objects(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[bpy.types.Object, None, None]:
for array_modifier in cls.get_modifiers_data(parent_element):
yield from cls.get_children_objects(array_modifier)
@classmethod
def get_modifiers_data(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[dict[str, Any], None, None]:
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
yield from json.loads(array_pset["Data"])
@classmethod
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
child_guid: str
for child_guid in modifier_data["children"]:
child_obj = tool.Blender.get_object_from_guid(child_guid)
if child_obj:
yield child_obj
class Attribute:
@classmethod
@@ -2071,7 +1888,7 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_radiance_exporter_props(cls) -> RadianceExporterProperties:
assert (scene := bpy.context.scene)
return scene.BIMRadianceExporeterProperies # pyright: ignore[reportAttributeAccessIssue]
return scene.BIMRadianceExporterProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_fm_props(cls) -> BIMFMProperties:
-111
View File
@@ -32,7 +32,6 @@ from __future__ import annotations
import math
import sys
from collections.abc import Sequence
from typing import TYPE_CHECKING, Union
import bmesh
@@ -46,13 +45,6 @@ if TYPE_CHECKING:
VTX_PRECISION = 1.0e-5
# Tolerances below are in Blender units (SI metres).
# Looser than VTX_PRECISION because regen-time numeric drift exceeds CAD snap precision.
WELD_TOLERANCE = 1.0e-4
# How close a vertex must be to the cut plane to count as on it.
BISECT_TOLERANCE = 1.0e-4
# Strict weld for cleaning up exactly-coincident vertices.
WELD_EPSILON = 1.0e-6
class Cad:
@@ -1004,106 +996,3 @@ class Cad:
y = height_half + height_half * (prj[1] / w)
return Vector((float(x), float(y)))
return default
@classmethod
def sweep_disk_along_polyline(
cls,
bm: bmesh.types.BMesh,
points: Sequence[Vector],
radius: float,
arc_indices: Sequence[int] = (),
profile_segments: int = 8,
) -> None:
"""Append a tube of ``radius`` along the polyline ``points`` to ``bm``.
Viewport-quality approximation of an IFC ``IfcSweptDiskSolid``: each
consecutive pair of points becomes a capped cylinder. The cylinders
overlap at joints rather than being mitered the visual artifact is
negligible at typical handrail radii (~25mm) and acceptable for
live parametric-edit preview.
``arc_indices`` is accepted for API symmetry with the IFC builder
(which receives the same data structure), but is currently unused
arcs are visualised as polyline kinks. Tessellating each arc with a
Lagrange or circular interpolation would smooth the joints; deferred
until profile fidelity becomes a concern.
:param bm: target bmesh, mutated in place.
:param points: polyline vertices.
:param radius: tube radius (project units).
:param arc_indices: indices of arc midpoints (currently ignored).
:param profile_segments: sides on each cylinder cross-section.
"""
del arc_indices # accepted for forward compatibility; see docstring
if len(points) < 2:
return
for p0, p1 in zip(points, points[1:]):
cls._add_capped_cylinder(bm, Vector(p0), Vector(p1), radius, profile_segments)
@classmethod
def add_disk_extrusion(
cls,
bm: bmesh.types.BMesh,
position: Vector,
radius: float,
depth: float,
axis_rotation_z: float,
profile_segments: int = 12,
) -> None:
"""Append a flat cylinder (disk extrusion) to ``bm``.
A disk of ``radius`` extruded by ``depth`` along the +Y axis rotated
by ``axis_rotation_z`` radians around Z. ``position`` is the disk's
base, not its centre.
:param bm: target bmesh, mutated in place.
:param position: base of the extrusion in object-local coordinates.
:param radius: disk radius.
:param depth: extrusion depth along the (rotated) Y axis.
:param axis_rotation_z: rotation around Z applied to the +Y axis to
obtain the extrusion direction.
:param profile_segments: sides on the disk's edge.
"""
# The +Y axis rotated by axis_rotation_z around Z gives the extrusion
# direction: (-sin(θ), cos(θ), 0). The disk axis points along it.
axis = Vector((-math.sin(axis_rotation_z), math.cos(axis_rotation_z), 0.0))
end = position + axis * depth
cls._add_capped_cylinder(bm, position, end, radius, profile_segments)
@classmethod
def _add_capped_cylinder(
cls,
bm: bmesh.types.BMesh,
p0: Vector,
p1: Vector,
radius: float,
segments: int,
) -> None:
"""Append one capped cylinder of ``radius`` from ``p0`` to ``p1`` to ``bm``."""
direction = p1 - p0
length = direction.length
if length < 1e-9:
return
direction = direction / length
z_axis = Vector((0.0, 0.0, 1.0))
dot = direction.dot(z_axis)
if dot > 1.0 - 1e-6:
rotation = Matrix.Identity(4)
elif dot < -1.0 + 1e-6:
# Anti-parallel: rotate 180° around X so the cone flips bottom-to-top.
rotation = Matrix.Rotation(math.pi, 4, "X")
else:
rotation = z_axis.rotation_difference(direction).to_matrix().to_4x4()
matrix = Matrix.Translation((p0 + p1) * 0.5) @ rotation
bmesh.ops.create_cone(
bm,
cap_ends=True,
cap_tris=False,
segments=segments,
radius1=radius,
radius2=radius,
depth=length,
matrix=matrix,
)
-1
View File
@@ -135,7 +135,6 @@ class Collector(bonsai.core.tool.Collector):
if element.is_a("IfcFeatureElementSubtraction"):
obj.display_type = "WIRE"
obj.display.show_shadows = False
@classmethod
def _create_project_child_collection(cls, name: str) -> bpy.types.Collection:
+1 -2
View File
@@ -987,8 +987,7 @@ class Cost(bonsai.core.tool.Cost):
def disable_editing_cost_item_parent(cls) -> None:
props = cls.get_cost_props()
props.active_cost_item_id = 0
if props.change_cost_item_parent == True:
props.change_cost_item_parent = False
props.change_cost_item_parent = False
@classmethod
def load_cost_item_quantities(cls, cost_item: Optional[ifcopenshell.entity_instance] = None) -> None:
-4
View File
@@ -1756,10 +1756,6 @@ class Drawing(bonsai.core.tool.Drawing):
# For section/elevation views, elevate the segment vertically
if not (points := helper.elevate_segment(bounds, [v1, v2])):
return
elif target_view == "MODEL_VIEW":
# For model views, clip to XY bounds and keep Z (3D line at true elevation)
if not (points := helper.clip_segment(bounds, [v1, v2])):
return
else:
return
-328
View File
@@ -1,328 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
import bpy
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import bonsai.core.geometry
import bonsai.core.tool
import bonsai.tool as tool
@dataclass
class DecompositionRecord:
type: Literal["fill"]
element: ifcopenshell.entity_instance
@dataclass
class ConnectionRecord:
type: Literal["path"]
relating_element: ifcopenshell.entity_instance
related_element: ifcopenshell.entity_instance
relating_connection_type: str
related_connection_type: str
relating_priorities: list[int]
related_priorities: list[int]
@dataclass
class PortConnectionRecord:
relating_port_index: int
related_element: ifcopenshell.entity_instance
related_port_index: int
direction: str
@dataclass
class PortConnectionSnapshot:
"""Port-to-port connections and per-element port counts captured before duplication."""
by_element: dict[ifcopenshell.entity_instance, list[PortConnectionRecord]] = field(default_factory=dict)
port_counts: dict[ifcopenshell.entity_instance, int] = field(default_factory=dict)
class Duplicate(bonsai.core.tool.Duplicate):
_pending_warnings: list[str] = []
@classmethod
def _emit_warning(cls, message: str) -> None:
"""Buffer a warning for later retrieval by an operator. Falling through
to a print keeps the message in the Blender console for the headless /
no-operator code path."""
cls._pending_warnings.append(message)
print(f"Bonsai: WARNING — {message}")
@classmethod
def consume_warnings(cls) -> list[str]:
"""Return and clear the buffered warnings — operators call this after
``tool.Geometry.duplicate_ifc_objects`` to forward each to ``self.report``."""
warnings = cls._pending_warnings
cls._pending_warnings = []
return warnings
@classmethod
def get_decomposition_relationships(
cls, objs: list[bpy.types.Object]
) -> dict[ifcopenshell.entity_instance, DecompositionRecord]:
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord] = {}
for obj in objs:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if building := tool.Spatial.get_host_element(element):
relationships[element] = DecompositionRecord(type="fill", element=building)
return relationships
@classmethod
def get_connection_relationships(
cls, objs: list[bpy.types.Object]
) -> dict[ifcopenshell.entity_instance, ConnectionRecord]:
relationships: dict[ifcopenshell.entity_instance, ConnectionRecord] = {}
for obj in objs:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if hasattr(element, "ConnectedTo") and element.ConnectedTo:
paths = [
connection for connection in element.ConnectedTo if connection.is_a("IfcRelConnectsPathElements")
]
for path in paths:
relationships[element] = ConnectionRecord(
type="path",
relating_element=path.RelatingElement,
related_element=path.RelatedElement,
relating_connection_type=path.RelatingConnectionType,
related_connection_type=path.RelatedConnectionType,
relating_priorities=list(path.RelatingPriorities or []),
related_priorities=list(path.RelatedPriorities or []),
)
return relationships
@classmethod
def get_port_connection_relationships(cls, objs: list[bpy.types.Object]) -> PortConnectionSnapshot:
"""Snapshot ``IfcRelConnectsPorts`` among MEP elements in ``objs``, indexed for positional-port replay onto duplicates."""
# Function-local: top-level import would trigger a partial-init cycle.
from bonsai.tool.system import direction_from_port_pair
snapshot = PortConnectionSnapshot()
elements_in_set: set[ifcopenshell.entity_instance] = set()
for obj in objs:
element = tool.Ifc.get_entity(obj)
if element is not None and tool.System.is_mep_element(element):
elements_in_set.add(element)
if not elements_in_set:
return snapshot
ordered_elements = sorted(elements_in_set, key=lambda e: e.id())
for element in ordered_elements:
snapshot.port_counts[element] = len(tool.System.get_ports(element))
seen: set[tuple[tuple[int, int], tuple[int, int]]] = set()
for element in ordered_elements:
ports = tool.System.get_ports(element)
for port_index, port in enumerate(ports):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
other_element = tool.System.get_port_relating_element(connected_port)
if other_element is None or other_element not in elements_in_set:
continue
other_ports = tool.System.get_ports(other_element)
try:
other_port_index = other_ports.index(connected_port)
except ValueError:
continue
pair_key = tuple(
sorted(
[
(element.id(), port_index),
(other_element.id(), other_port_index),
]
)
)
if pair_key in seen:
continue
seen.add(pair_key)
snapshot.by_element.setdefault(element, []).append(
PortConnectionRecord(
relating_port_index=port_index,
related_element=other_element,
related_port_index=other_port_index,
direction=direction_from_port_pair(port, connected_port),
)
)
return snapshot
@classmethod
def recreate_decompositions(
cls,
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for subelement, data in relationships.items():
new_subelements = old_to_new.get(subelement)
new_elements = old_to_new.get(data.element)
if not new_subelements or not new_elements:
continue
for i, new_subelement in enumerate(new_subelements):
new_element = new_elements[i]
if data.type == "fill":
element = new_element
filling = new_subelement
voided_obj = tool.Ifc.get_object(new_element)
filling_obj = tool.Ifc.get_object(new_subelement)
existing_opening_occurrence = subelement.FillsVoids[0].RelatingOpeningElement
opening = tool.Ifc.run("root.copy_class", product=existing_opening_occurrence)
tool.Ifc.run(
"geometry.edit_object_placement",
product=opening,
matrix=ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement),
is_si=False,
)
representation = ifcopenshell.util.representation.get_representation(
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
representation = ifcopenshell.util.representation.resolve_representation(representation)
mapped_representation = tool.Ifc.run("geometry.map_representation", representation=representation)
tool.Ifc.run(
"geometry.assign_representation",
product=opening,
representation=mapped_representation,
)
tool.Ifc.run("feature.add_feature", feature=opening, element=element)
tool.Ifc.run("feature.add_filling", opening=opening, element=filling)
voided_objs = [voided_obj]
# Openings affect all subelements of an aggregate
for child_subelement in ifcopenshell.util.element.get_decomposition(element):
subobj = tool.Ifc.get_object(child_subelement)
if subobj:
voided_objs.append(subobj)
for voided_obj in voided_objs:
if mesh_data := voided_obj.data:
representation = tool.Ifc.get().by_id(
tool.Geometry.get_mesh_props(mesh_data).ifc_definition_id
)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
@classmethod
def recreate_connections(
cls,
relationship: dict[ifcopenshell.entity_instance, ConnectionRecord],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for element, data in relationship.items():
try:
new_relating_element = old_to_new.get(data.relating_element)[0]
new_related_element = old_to_new.get(data.related_element)[0]
except (KeyError, IndexError, TypeError):
continue
new_rel = tool.Ifc.run(
"geometry.connect_path",
relating_element=new_relating_element,
related_element=new_related_element,
relating_connection=data.relating_connection_type,
related_connection=data.related_connection_type,
)
# connect_path hardcodes priorities to []; restore them post-hoc.
priority_attrs: dict[str, Any] = {}
if data.relating_priorities:
priority_attrs["RelatingPriorities"] = data.relating_priorities
if data.related_priorities:
priority_attrs["RelatedPriorities"] = data.related_priorities
if new_rel is not None and priority_attrs:
try:
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(
f"connection priority restore failed for {new_rel}; "
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
)
@classmethod
def recreate_port_connections(
cls,
snapshot: PortConnectionSnapshot,
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
for relating_element, records in snapshot.by_element.items():
for record in records:
related_element = record.related_element
try:
new_relating = old_to_new[relating_element][0]
new_related = old_to_new[related_element][0]
except (KeyError, IndexError):
continue
new_relating_ports = tool.System.get_ports(new_relating)
new_related_ports = tool.System.get_ports(new_related)
expected_relating = snapshot.port_counts.get(relating_element)
if expected_relating is not None and len(new_relating_ports) != expected_relating:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
f"snapshot had {expected_relating}"
)
continue
expected_related = snapshot.port_counts.get(related_element)
if expected_related is not None and len(new_related_ports) != expected_related:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
f"snapshot had {expected_related}"
)
continue
try:
new_port_a = new_relating_ports[record.relating_port_index]
new_port_b = new_related_ports[record.related_port_index]
except IndexError:
cls._emit_warning(
f"port reconnect skipped — record references port index past the duplicate's port list"
)
continue
try:
tool.Ifc.run(
"system.connect_port",
port1=new_port_a,
port2=new_port_b,
direction=record.direction or "NOTDEFINED",
)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
+19 -158
View File
@@ -73,7 +73,7 @@ import bonsai.core.style
import bonsai.core.system
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
from bonsai.bim.ifc import IfcStore
if TYPE_CHECKING:
from bonsai.bim.module.geometry.prop import (
@@ -115,42 +115,10 @@ class Geometry(bonsai.core.tool.Geometry):
@classmethod
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
# Cache acquisition can fail if the HDF5 file is locked by another
# process — degrade gracefully rather than aborting the caller's
# reimport flow. A stale cache entry is harmless; a raised exception
# prevents the actual mesh swap. The wrapper sets the project-panel
# warning flag on lock so the user sees one prominent notice instead
# of per-element log spam.
try:
cache = get_cache_or_detect_lock()
except Exception as exc:
print(f"clear_cache: skipping cache invalidation for {element} ({exc})")
return
cache = IfcStore.get_cache()
if cache and hasattr(element, "GlobalId"):
cache.remove(element.GlobalId)
@classmethod
def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool:
"""True if the element carries a shape representation whose
RepresentationIdentifier is 'Axis'. Elements without one cannot be
projected to an unambiguous 1D path; callers that draw schematic axis
overlays must skip them rather than fall back to mesh-derived geometry."""
product_rep = getattr(element, "Representation", None)
if product_rep is None:
return False
for rep in product_rep.Representations:
if getattr(rep, "RepresentationIdentifier", None) == "Axis":
return True
return False
@classmethod
def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``.
Single source for the ``(context, identifier, target_view)`` triple used
by every body-geometry reader across walls, slabs, doors, openings, and
feature decorators."""
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
@classmethod
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
for modifier in obj.modifiers:
@@ -257,13 +225,7 @@ class Geometry(bonsai.core.tool.Geometry):
break
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh)
item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id
try:
item = tool.Ifc.get().by_id(item_id)
except RuntimeError:
# Entity already deleted (e.g. removed as part of a sibling boolean collapse).
bpy.data.objects.remove(obj)
return
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
rep_obj = props.representation_obj
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
cls.remove_representation_item(item, rep_element)
@@ -427,29 +389,6 @@ class Geometry(bonsai.core.tool.Geometry):
bm.free()
del mesh["ios_edges"]
@classmethod
def get_dissolved_edges(
cls,
mesh: bpy.types.Mesh,
angle_limit: float = radians(1.0),
) -> tuple[list[Vector], list[tuple[int, int]]]:
# Read-only on `mesh`: builds a throwaway bmesh, dissolves coplanar
# edges while preserving material seams, returns wire-overlay data.
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.dissolve_limit(
bm,
angle_limit=angle_limit,
verts=bm.verts,
edges=bm.edges,
delimit={"MATERIAL"},
)
bm.verts.index_update()
verts = [v.co.copy() for v in bm.verts]
edges = [(e.verts[0].index, e.verts[1].index) for e in bm.edges]
bm.free()
return verts, edges
@classmethod
def apply_item_ids_as_vertex_groups(cls, obj: bpy.types.Object) -> None:
"""Save mesh-object item_ids as vertex groups in format 'ios_item_id_xxxx'.
@@ -849,15 +788,6 @@ class Geometry(bonsai.core.tool.Geometry):
return True
return False
@classmethod
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when any of ``element``'s materials exposes an
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
return any(
tool.Material.get_style(material) is not None
for material in ifcopenshell.util.element.get_materials(element)
)
@classmethod
def reimport_element_representations(
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
@@ -1163,16 +1093,11 @@ class Geometry(bonsai.core.tool.Geometry):
@classmethod
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
data = obj.data
if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES):
return None
ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id
if not ifc_id:
return None
try:
item = tool.Ifc.get().by_id(ifc_id)
except RuntimeError:
return None
if item.is_a("IfcRepresentationItem"):
if (
isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem"))
):
return item
return None
@@ -1229,53 +1154,6 @@ class Geometry(bonsai.core.tool.Geometry):
props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
@classmethod
def commit_placement_if_moved(cls, obj: bpy.types.Object, *, apply_scale: bool = True) -> None:
"""Write ``obj.matrix_world`` back to its IFC ``ObjectPlacement`` when the
object has drifted since its last placement commit.
Scope: drop-in only when the gate is exactly ``is_moved(obj)``. Call sites
whose gate is wider (e.g. ``is_moved OR is_scaled``) or already enforced
upstream (inside an ``if is_moved:`` block) should call
``edit_object_placement`` directly to avoid the redundant inner check."""
if not tool.Ifc.is_moved(obj):
return
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=apply_scale
)
@classmethod
def restore_placement_from_ifc(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
"""Snap ``obj.matrix_world`` back to ``element``'s committed IFC placement,
then re-baseline the drift checksum so ``tool.Ifc.is_moved(obj)`` returns
False afterwards.
Precondition: ``element.ObjectPlacement`` must not be None. Callers in a
cancel-style flow that want a "restore-or-clear-drift" semantic must gate
on ObjectPlacement themselves and call ``record_object_position`` directly
in the no-placement branch."""
assert element.ObjectPlacement is not None, (
"restore_placement_from_ifc requires ObjectPlacement — gate the caller "
"or use restore_or_rebaseline_placement for the restore-or-clear-drift semantic"
)
matrix_np = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
matrix_np[:3, 3] *= unit_scale
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix_np)
cls.record_object_position(obj)
@classmethod
def restore_or_rebaseline_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
"""Cancel-flow placement restore: revert ``obj.matrix_world`` to the committed
IFC placement; when the element has no ObjectPlacement, re-baseline the drift
checksum instead so a subsequent edit does not silently commit the discarded drag."""
if not tool.Ifc.is_moved(obj):
return
if element.ObjectPlacement is None:
cls.record_object_position(obj)
return
cls.restore_placement_from_ifc(obj, element)
@classmethod
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
tool.Ifc.get().remove(connection)
@@ -1327,27 +1205,11 @@ class Geometry(bonsai.core.tool.Geometry):
bpy.data.objects.remove(obj)
return new_obj
@classmethod
def detach_representation(cls, product: ifcopenshell.entity_instance) -> None:
"""Replace ``product.Representation`` with a deep copy so the product
no longer shares its representation tree (mapped or direct) with any
other entity. The ``IfcGeometricRepresentationContext`` is excluded
from the copy so contexts stay file-singletons. No-op when the
product has no ``Representation`` attribute or it is unset."""
rep = getattr(product, "Representation", None)
if rep is None:
return
product.Representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), rep, exclude=["IfcGeometricRepresentationContext"]
)
@classmethod
def resolve_mapped_representation(
cls, representation: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
if representation.RepresentationType == "MappedRepresentation":
if not representation.Items:
return representation
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
return representation
@@ -2270,11 +2132,8 @@ class Geometry(bonsai.core.tool.Geometry):
new_active_obj = None
# Track decompositions so they can be recreated after the operation
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate)
connection_relationships = tool.Duplicate.get_connection_relationships(objects_to_duplicate)
# Snapshot port-to-port connections — copy_class disconnects new ports
# by default, leaving Shift+D duplicates unconnected.
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(objects_to_duplicate)
decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate)
connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate)
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
old_obj_name_to_new_obj_name: dict[str, str] = {}
@@ -2296,7 +2155,10 @@ class Geometry(bonsai.core.tool.Geometry):
keep_data_linked = linked and not element and not is_tracked_opening
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
cls.commit_placement_if_moved(obj, apply_scale=False)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False
)
new_obj = obj.copy()
temp_data = None
@@ -2350,7 +2212,7 @@ class Geometry(bonsai.core.tool.Geometry):
array_data = arrays_to_duplicate.get(obj, None)
tool.Model.handle_array_on_copied_element(new, array_data)
if array_data:
for child in tool.Array.get_all_children_objects(new):
for child in tool.Blender.Modifier.Array.get_all_children_objects(new):
child.select_set(True)
# TODO: add new array children to recreate their decomposition too
@@ -2378,11 +2240,10 @@ class Geometry(bonsai.core.tool.Geometry):
# Remove connections with old objects and recreates paths
cls.remove_old_connections(old_to_new)
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
tool.Root.recreate_connections(connection_relationships, old_to_new)
# Recreate decompositions
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
tool.Root.recreate_decompositions(decomposition_relationships, old_to_new)
cls.remove_linked_aggregate_data(old_to_new)
bonsai.bim.handler.refresh_ui_data()
tool.Root.reload_grid_decorator()
@@ -2447,8 +2308,8 @@ class Geometry(bonsai.core.tool.Geometry):
continue
array_data = []
for modifier_data in tool.Array.get_modifiers_data(array_parent):
children = set(tool.Array.get_children_objects(modifier_data))
for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent):
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
if children.issubset(selected_objects):
modifier_data["children"] = []
array_data.append(modifier_data)
+41 -327
View File
@@ -15,14 +15,12 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
import collections.abc
import json
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Iterable, Sequence
from copy import deepcopy
from math import atan, cos, degrees, pi, radians
from typing import (
@@ -39,11 +37,9 @@ from typing import (
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.feature
import ifcopenshell.api.geometry
import ifcopenshell.api.grid
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.element
@@ -62,7 +58,6 @@ import bonsai.core.geometry
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE
T = TypeVar("T")
V_ = tool.Blender.V_
@@ -82,7 +77,6 @@ if TYPE_CHECKING:
BIMRoofProperties,
BIMStairProperties,
BIMSverchokProperties,
BIMWallProperties,
BIMWindowProperties,
)
@@ -104,10 +98,6 @@ class Model(bonsai.core.tool.Model):
def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties:
return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_wall_props(cls, obj: bpy.types.Object) -> BIMWallProperties:
return obj.BIMWallProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties:
return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -133,35 +123,6 @@ class Model(bonsai.core.tool.Model):
assert (scene := bpy.context.scene)
return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def resolve_active_props_for_edit(
cls,
context: bpy.types.Context,
props_getter: Callable[[bpy.types.Object], Any],
*,
subtype: Optional[tuple[str, Any]] = None,
) -> Optional[tuple[bpy.types.Object, Any]]:
"""Resolve ``(obj, props)`` for an operator that acts on the active
object only while a parametric edit is active.
Returns ``None`` (the operator should ``return {"CANCELLED"}``) when
any of these fail:
- no active object,
- ``props.is_editing`` is False,
- ``subtype`` is given as ``(attr, value)`` and ``props.<attr> != value``.
"""
obj = context.active_object
if not obj:
return None
props = props_getter(obj)
if not getattr(props, "is_editing", False):
return None
if subtype is not None:
attr, value = subtype
if getattr(props, attr, None) != value:
return None
return obj, props
@classmethod
def convert_si_to_unit(cls, value: T) -> T:
if isinstance(value, (tuple, list)):
@@ -351,8 +312,6 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Return first found IfcExtrudedAreaSolid"""
if not representation.Items:
return None
item = representation.Items[0]
while True:
if item.is_a("IfcExtrudedAreaSolid"):
@@ -833,7 +792,7 @@ class Model(bonsai.core.tool.Model):
assert element or representation, "Either element or representation must be provided."
if representation is None:
assert element
representation = tool.Geometry.get_body_representation(element)
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
return []
booleans = []
@@ -845,57 +804,6 @@ class Model(bonsai.core.tool.Model):
items.append(item.FirstOperand)
return booleans
@classmethod
def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP)."""
result = []
for rel in wall.ConnectedFrom:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
slab_obj = tool.Ifc.get_object(rel.RelatingElement)
if slab_obj:
result.append(slab_obj)
return result
@classmethod
def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP)."""
result = []
for rel in slab.ConnectedTo:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
wall_obj = tool.Ifc.get_object(rel.RelatedElement)
if wall_obj:
result.append(wall_obj)
return result
@classmethod
def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool:
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
@classmethod
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
manual_booleans = cls.get_manual_booleans(wall)
if not manual_booleans:
return
ifc_file = tool.Ifc.get()
for b in manual_booleans:
sec = b.SecondOperand
if sec is None:
# The IfcPolygonalFaceSet was already deleted externally. Splice the
# orphaned IfcBooleanResult out of the chain so the representation stays valid.
parents = list(ifc_file.get_inverse(b))
for parent in parents:
if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b:
parent.FirstOperand = b.FirstOperand
elif parent.is_a("IfcShapeRepresentation"):
new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand})
parent.Items = new_items
cls.unmark_manual_booleans(wall, [b.id()])
ifc_file.remove(b)
elif sec.is_a("IfcTessellatedFaceSet"):
tool.Geometry.remove_representation_item(sec, wall)
@classmethod
def get_manual_booleans(
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
@@ -905,11 +813,10 @@ class Model(bonsai.core.tool.Model):
return []
boolean_ids = json.loads(pset["Data"])
if representation is None:
representation = tool.Geometry.get_body_representation(element)
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
return []
all_chain_booleans = cls.get_booleans(element, representation)
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
return booleans
@classmethod
@@ -995,7 +902,7 @@ class Model(bonsai.core.tool.Model):
# Revolved area check should happen inside bim.enable_editing_extrusion_axis
# but keep it here to trigger import_representation_items,
# so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support.
body = tool.Geometry.get_body_representation(element)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body and any(
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
):
@@ -1108,14 +1015,7 @@ class Model(bonsai.core.tool.Model):
def handle_array_on_copied_element(
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
) -> None:
"""Post-copy hook: decide what to do with the BBIM_Array pset a copy
inherits from its source.
- ``array_data=None`` detach the copy from any array. Removes the
inherited BBIM_Array pset and any CHILD_OF constraint.
- ``array_data`` provided promote the copy to a fresh array parent
with an empty children list, using the provided layer config.
"""
"""if no `array_data` is provided then an array will be removed from the element"""
if array_data is None:
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
@@ -1159,8 +1059,8 @@ class Model(bonsai.core.tool.Model):
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
for i in range(len(array_data)):
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
@classmethod
def regenerate_array(
@@ -1197,17 +1097,12 @@ class Model(bonsai.core.tool.Model):
offset = base_offset * i
for obj in obj_stack:
# IndexError when child_i is past the recorded children list
# (count grew); RuntimeError when by_guid finds no entity (the
# child was deleted outside the array op); AssertionError when
# the IFC entity exists but its Blender object was unlinked.
# All three fall through to duplication.
try:
global_id = array["children"][child_i]
child_element = tool.Ifc.get().by_guid(global_id)
child_obj = tool.Ifc.get_object(child_element)
assert child_obj
except (IndexError, RuntimeError, AssertionError):
except:
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
child_obj = tool.Ifc.get_object(child_element)
@@ -1244,24 +1139,14 @@ class Model(bonsai.core.tool.Model):
removed_children = set(existing_children) - set(array["children"])
for removed_child in removed_children:
element = tool.Ifc.get().by_guid(removed_child)
# Strip any wall/slab opening cut by this child before deletion,
# so the host's HasOpenings shrinks symmetrically with count.
if getattr(element, "FillsVoids", None):
ifcopenshell.api.feature.remove_feature(
tool.Ifc.get(), feature=element.FillsVoids[0].RelatingOpeningElement
)
obj = tool.Ifc.get_object(element)
if obj:
tool.Geometry.delete_ifc_object(obj)
if array.get("per_child_opening", array.get("mirror_to_host", True)) and children_elements:
cls.mirror_parent_void_fillings_to_children(parent_element, children_elements)
if array_i in array_layers_to_apply:
for child_element in children_elements:
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
cls.unshare_opening_representation(child_element)
array["children"] = []
array["count"] = 1
@@ -1274,112 +1159,6 @@ class Model(bonsai.core.tool.Model):
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
)
# Post-condition: parent is selected on return. duplicate_ifc_objects
# deselects the source on every call inside the regen loop; without
# this restore, callers get a deselected parent for arrays with N >= 2.
# TODO: batch the per-child duplicate_ifc_objects([parent]) calls into
# a single N-way duplicate — N depsgraph churns + N select/deselect
# flips is wasteful, and a batched duplicate would also remove the
# need for this restore.
parent_obj.select_set(True)
@classmethod
def mirror_parent_void_fillings_to_children(
cls,
parent_element: ifcopenshell.entity_instance,
children_elements: Sequence[ifcopenshell.entity_instance],
) -> None:
"""Replicate the parent's FillsVoids → host chain onto each array child.
For each child, tears down any stale opening, creates a new
IfcOpeningElement at the child's current placement, reuses the parent's
opening representation as a MappedRepresentation, and adds the
void + filling pair so the host element is cut once per child.
No-op when the parent is not a filling, when the host element cannot
be resolved, or when the children list is empty. Opt out via the
per-layer ``per_child_opening`` flag on ``BBIM_Array.Data`` (legacy
key ``mirror_to_host`` still honoured for round-trip with older files).
"""
host = tool.Spatial.get_host_element(parent_element)
if host is None or not children_elements:
return
ifc_file = tool.Ifc.get()
parent_opening = parent_element.FillsVoids[0].RelatingOpeningElement
parent_opening_rep = ifcopenshell.util.representation.get_representation(
parent_opening, "Model", "Body", "MODEL_VIEW"
)
if parent_opening_rep is None:
return
parent_opening_rep = ifcopenshell.util.representation.resolve_representation(parent_opening_rep)
for child in children_elements:
if getattr(child, "FillsVoids", None):
ifcopenshell.api.feature.remove_feature(ifc_file, feature=child.FillsVoids[0].RelatingOpeningElement)
child_obj = tool.Ifc.get_object(child)
if child_obj is None:
continue
new_opening = ifcopenshell.api.root.create_entity(
ifc_file,
ifc_class="IfcOpeningElement",
predefined_type="OPENING",
name="Opening",
)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=new_opening,
matrix=np.array(child_obj.matrix_world),
is_si=True,
)
mapped_representation = ifcopenshell.api.geometry.map_representation(
ifc_file, representation=parent_opening_rep
)
ifcopenshell.api.geometry.assign_representation(
ifc_file, product=new_opening, representation=mapped_representation
)
ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=host)
ifcopenshell.api.feature.add_filling(ifc_file, opening=new_opening, element=child)
# Openings affect every sub-element of an aggregate, not just the named host.
voided_objs: list[bpy.types.Object] = []
host_obj = tool.Ifc.get_object(host)
if host_obj is not None:
voided_objs.append(host_obj)
for subelement in tool.Aggregate.get_parts_recursively(host):
subobj = tool.Ifc.get_object(subelement)
if subobj is not None:
voided_objs.append(subobj)
for voided_obj in voided_objs:
if not voided_obj.data:
continue
voided_element = tool.Ifc.get_entity(voided_obj)
if voided_element is None:
continue
context = tool.Geometry.get_active_representation_context(voided_obj)
representation = tool.Geometry.get_representation_by_context(voided_element, context)
if representation is None:
continue
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
)
@classmethod
def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None:
"""Detach a filling's opening representation from any shared mapped body.
Required when a Bonsai array child is promoted to an independent
object: the array's per-child opening mirror builds each child's
opening representation as an ``IfcMappedRepresentation`` over the
parent opening's body. Without this detach, a later edit replacing
the parent body rewrites the shared ``IfcRepresentationMap`` and
reshapes the former-child's opening too."""
if not getattr(filling, "FillsVoids", None):
return
tool.Geometry.detach_representation(filling.FillsVoids[0].RelatingOpeningElement)
@classmethod
def replace_object_ifc_representation(
cls,
@@ -1526,8 +1305,8 @@ class Model(bonsai.core.tool.Model):
return [obj for obj in tool.Blender.get_selected_objects() if tool.Ifc.get_entity(obj)]
@classmethod
def has_selected_ifc_objects(cls, include_active: bool = True) -> bool:
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects(include_active=include_active))
def has_selected_ifc_objects(cls) -> bool:
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects())
@classmethod
def get_selected_mesh_objects(cls) -> list[bpy.types.Object]:
@@ -1576,7 +1355,8 @@ class Model(bonsai.core.tool.Model):
@classmethod
def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None:
"""make sure IFC position will be in sync with the Blender object position, if object was moved in Blender"""
tool.Geometry.commit_placement_if_moved(obj)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
@classmethod
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
@@ -1608,7 +1388,7 @@ class Model(bonsai.core.tool.Model):
if not obj.data:
continue
element = tool.Ifc.get_entity(obj)
body = tool.Geometry.get_body_representation(element)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
@@ -1725,10 +1505,6 @@ class Model(bonsai.core.tool.Model):
"TRIPLE_PANEL_VERTICAL",
]
RoofGenerationMethod = Literal["HEIGHT", "ANGLE"]
RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"]
@classmethod
def generate_stair_2d_profile(
cls,
@@ -1980,7 +1756,7 @@ class Model(bonsai.core.tool.Model):
from bonsai.bim.module.model.opening import FilledOpeningGenerator
ifc_file = tool.Ifc.get()
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)}
voided_objs = set()
has_replaced_opening_representation = False
@@ -2122,9 +1898,7 @@ class Model(bonsai.core.tool.Model):
bm = bmesh.new()
bm.from_mesh(mesh)
# Looser than auto_detect_curves' VTX_PRECISION: profiles must close into
# a single loop, so nearly-coincident endpoints should snap together.
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
@@ -2352,7 +2126,7 @@ class Model(bonsai.core.tool.Model):
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
@@ -2571,12 +2345,6 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float:
"""Signed slope of the extrusion's direction in the y-z plane (radians).
Assumes extrusion directions lie in the y-z plane (LAYER2 wall and
LAYER3 slab convention). For inverted extrusions (z 0), adds π to
preserve angular continuity for callers consuming the angle via
cos/sin."""
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
vector = Vector((0, 1))
x_angle = vector.angle_signed(Vector((y, z)))
@@ -2611,15 +2379,12 @@ class Model(bonsai.core.tool.Model):
clipping_bm = bmesh.new()
vertex_map = {}
kept = 0
for face in bm.faces:
face.normal_update()
normal = face.normal.to_4d()
normal.w = 0
world_normal_z = (obj.matrix_world @ normal).z
if world_normal_z >= -0.5:
if (obj.matrix_world @ normal).z >= -0.5:
continue
kept += 1
new_verts = []
for vert in face.verts:
if not (new_vert := vertex_map.get(vert.index, None)):
@@ -2632,7 +2397,6 @@ class Model(bonsai.core.tool.Model):
return
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
clipping_bm.faces.ensure_lookup_table()
return clipping_bm # clipping_bm is in project units
@classmethod
@@ -2646,53 +2410,17 @@ class Model(bonsai.core.tool.Model):
min_z = min(zs)
max_z = max(zs)
ifc_file = tool.Ifc.get()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
operand = None
if (z := max_z - min_z) and not np.isclose(z, 0.0):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
# Build one IfcPolygonalFaceSet clip solid per clipping face.
# Each solid uses a rectangle on the slope plane rather than the exact face
# footprint. The original approach (exact footprint) caused a kissing-solid /
# boundary-coincidence bug when the operator is called twice for a ridge roof: the
# two slope solids share an exact ridge edge, and OCCT produces spurious extra
# vertices. Extending each solid slightly past the ridge (by margin) creates a
# volumetric overlap instead of a kissing boundary — OCCT handles overlapping
# DIFFERENCE operands correctly.
margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge
operands = []
for face in bm.faces:
face.normal_update()
normal = Vector(face.normal).normalized()
result = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)]
bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z))
# Orthonormal basis spanning the slope plane.
ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0))
tangent1 = normal.cross(ref).normalized()
tangent2 = normal.cross(tangent1).normalized()
centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts)
# Tight bounding rectangle in slope-plane coords, plus a small margin.
t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts]
t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts]
half1 = max(abs(c) for c in t1_coords) + margin
half2 = max(abs(c) for c in t2_coords) + margin
# Rectangle on the slope plane, extruded upward in wall-local Z.
clip_bm = bmesh.new()
v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2)
v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2)
v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2)
v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2)
bottom_face = clip_bm.faces.new([v0, v1, v2, v3])
result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face])
top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)]
bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z)))
clip_bm.verts.ensure_lookup_table()
clip_verts = [v.co for v in clip_bm.verts]
clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces]
operand = builder.mesh(clip_verts, clip_faces)
clip_bm.free()
operands.append(operand)
verts = [v.co for v in bm.verts]
faces = [[v.index for v in p.verts] for p in bm.faces]
operand = builder.mesh(verts, faces)
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
if extrusion.Position:
@@ -2709,9 +2437,10 @@ class Model(bonsai.core.tool.Model):
extrusion.Depth = max_z / direction[2]
if operands:
body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW")
booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands)
if operand:
booleans = ifcopenshell.api.geometry.add_boolean(
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
)
tool.Model.mark_manual_booleans(wall, booleans)
@classmethod
@@ -2964,20 +2693,6 @@ class Model(bonsai.core.tool.Model):
@classmethod
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
# Curved fillet-corner walls own a hand-built banana body that
# ``regenerate_wall_representation`` would flatten — it reads the axis
# as a 2-point reference line and builds a straight extrusion. Rebuild
# the curve in place instead: ``regenerate_fillet_corner_wall`` keeps
# radius + placement from the pset / current ``ObjectPlacement`` while
# picking up new thickness / height from the wall type, which is what
# we want when a type-property edit triggered this call.
if tool.Parametric.is_fillet_corner_wall(element):
# Lazy import: ``tool.Model`` loads before ``bim/module/model`` at
# addon enable; a module-level import would cycle.
from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall
regenerate_fillet_corner_wall(element, obj)
return
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
bonsai.core.geometry.switch_representation(
tool.Ifc,
@@ -2998,29 +2713,28 @@ class Model(bonsai.core.tool.Model):
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
for wall in walls:
element = tool.Ifc.get_entity(wall)
tool.Geometry.commit_placement_if_moved(wall)
if tool.Ifc.is_moved(wall):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
queue.add((element, wall))
for rel in getattr(element, "ConnectedTo", []):
obj = tool.Ifc.get_object(rel.RelatedElement)
tool.Geometry.commit_placement_if_moved(obj)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
queue.add((rel.RelatedElement, obj))
for rel in getattr(element, "ConnectedFrom", []):
obj = tool.Ifc.get_object(rel.RelatingElement)
tool.Geometry.commit_placement_if_moved(obj)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
queue.add((rel.RelatingElement, obj))
for element, wall in queue:
if not wall:
continue
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element)
if not (is_layer2_usage or is_fillet_corner):
continue
if is_layer2_usage:
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
# Use layer custom offset
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
material = ifcopenshell.util.element.get_material(element)
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
material.OffsetFromReferenceLine = custom_offset
cls.recreate_wall(element, wall)
cls.recreate_wall(element, wall)
@classmethod
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
-623
View File
@@ -1,623 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Registry and save-time auto-commit for parametric draft edits.
The registry is consumed along two orthogonal axes:
- **Predicate axis**: every entry carries an ``is_<name>`` total predicate. Used
by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls.
- **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True``
share the ``Enable/Finish/CancelEditing<Type>`` operator shape and are wired
through ``build_edit_lifecycle``. The remainder declare their edit operators
directly because their lifecycle (per-attribute diff dispatch, layer-stack
editing, mid-spline gizmo drag, ) does not fit the shared mixin contract.
Adding a new parametric element type is a single entry in ``EDIT_TYPES``;
flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches
one of the shared mixins in ``bim/parametric_lifecycle.py``."""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, Optional
import bpy
import bonsai.core.tool
import bonsai.tool as tool
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ifcopenshell import entity_instance
# Lowercase ASCII snake_case token; each segment a non-empty letter/digit
# sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``.
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
def _camel_case(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_"))
@dataclass(frozen=True)
class ParametricObject:
"""One parametric element type's draft + enable + finish + cancel edit lifecycle.
The ``name`` token drives every derived identifier: the
``BIM<Name>Properties`` attribute on ``bpy.types.Object``, the
``bim.enable_editing_<name>`` / ``bim.finish_editing_<name>`` /
``bim.cancel_editing_<name>`` operator ``bl_idname``s, and the
``tool.Parametric.is_<name>`` runtime predicate.
The predicate is part of the contract and MUST be total accept any IFC
entity, return a bool, never raise. A raising predicate breaks the save
path for every parametric type, not just its own.
``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the
shared mixin contract (``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets``) and that therefore wire their operators through
``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
False and declare their operator classes directly."""
name: str
has_non_editable_path: bool = False
supports_build_edit_lifecycle: bool = False
def __post_init__(self) -> None:
if not _VALID_NAME_RE.match(self.name):
raise ValueError(
f"ParametricObject name {self.name!r} must match "
f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, "
f"optionally split by single underscores (e.g. ``door`` or "
f"``pipe_segment``). Leading / trailing underscores and "
f"consecutive underscores are rejected because they produce "
f"empty CamelCase segments in derived class names."
)
@property
def props_attr(self) -> str:
return f"BIM{_camel_case(self.name)}Properties"
@property
def enable_op(self) -> str:
return f"bim.enable_editing_{self.name}"
@property
def finish_op(self) -> str:
return f"bim.finish_editing_{self.name}"
@property
def cancel_op(self) -> str:
return f"bim.cancel_editing_{self.name}"
def is_editing(self, obj: bpy.types.Object) -> bool:
props = getattr(obj, self.props_attr, None)
return bool(props and getattr(props, "is_editing", False))
class Parametric(bonsai.core.tool.Parametric):
class GenerationKeyedCache:
"""A dict-keyed cache stamped with the parametric generation counter
at fill time. Reads at a later generation drop the whole dict and
re-run the loader. Any IFC commit bumps the generation, invalidating
all entries en bloc.
``None`` values are stored verbatim; only "key not in dict" counts as
a miss."""
def __init__(self) -> None:
self._gen: int | None = None
self._data: dict = {}
def get_or_compute(self, key, loader):
current = Parametric.get_geom_generation()
if self._gen != current:
self._data.clear()
self._gen = current
if key not in self._data:
self._data[key] = loader()
return self._data[key]
def clear(self) -> None:
"""Explicit drop. Use from ``load_post`` so a fresh file starts clean."""
self._data.clear()
self._gen = None
# FIXME(PR4): array / pipe_segment / duct_segment land with their
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
# for objects flagged as in-edit, which then raises because the operator
# doesn't exist. PR4 re-adds the three entries together with the operators.
EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
]
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
# the binding loop at module bottom. Declared here so IDEs and type
# checkers see the attributes without running the loop.
DOOR: ClassVar[ParametricObject]
WINDOW: ClassVar[ParametricObject]
STAIR: ClassVar[ParametricObject]
RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
_geom_generation: int = 0
@classmethod
def get_geom_generation(cls) -> int:
return cls._geom_generation
@classmethod
def refresh_post_commit(cls) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
workspace-tool header fields from current IFC state and bumps the
geometry generation counter so caches keyed off it drop stale
entries on the next draw."""
import bonsai.bim.handler # late import: bim.handler imports tool.*
cls._geom_generation += 1
bonsai.bim.handler.update_bim_tool_props()
tool.Blender.update_all_viewports()
@classmethod
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
return next((f for f in cls.EDIT_TYPES if f.name == name), None)
@classmethod
def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool:
"""Resolve and invoke ``is_<feature.name>`` defensively. The contract is
that predicates are total (see ``ParametricObject`` docstring); a
regression that turns one predicate raising would otherwise break the
save path for every parametric type, not just its own."""
predicate = getattr(cls, f"is_{feature.name}", None)
if predicate is None:
return False
try:
return bool(predicate(element))
except Exception:
logger.warning(
"parametric predicate is_%s raised on %r",
feature.name,
element,
exc_info=True,
)
return False
@classmethod
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
"""Return the registry entry whose IFC type predicate matches ``element``."""
for feature in cls.EDIT_TYPES:
if cls._safe_predicate(feature, element):
return feature
return None
@classmethod
def is_object_editing(cls, obj: bpy.types.Object, skip_name: Optional[str] = None) -> Optional[ParametricObject]:
"""Return the registry entry whose edit lifecycle is active on ``obj``, or None.
``skip_name`` excludes one entry from the scan, for callers that want
to know if a *different* type is editing."""
for feature in cls.EDIT_TYPES:
if feature.name == skip_name:
continue
if feature.is_editing(obj):
return feature
return None
@classmethod
def _validated_editing_feature(cls, obj: bpy.types.Object) -> Optional[ParametricObject]:
"""Return the active registry entry on ``obj``, validated against the
per-type predicate. Returns None when no ``is_editing`` flag is set
or when the flag is stale.
Self-heals: a predicate mismatch clears the flag in place so the
finish dispatch never re-picks up a phantom edit."""
feature = cls.is_object_editing(obj)
if feature is None:
return None
element = tool.Ifc.get_entity(obj)
if element is None or not cls._safe_predicate(feature, element):
getattr(obj, feature.props_attr).is_editing = False
return None
return feature
@classmethod
def heal_stale_edit_flags(cls) -> None:
"""Validate every scene object's ``is_editing`` flag against the
per-type predicate, clearing stale flags in place.
Run from ``load_post`` so a ``.blend`` saved with phantom flags
(e.g. a save that bypassed the auto-commit flush) is consistent the
moment it opens."""
for obj in bpy.data.objects:
cls._validated_editing_feature(obj)
@classmethod
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
"""``(object, finish_operator_bl_idname)`` pairs for every object
with an in-progress parametric draft. Stale flags are cleared in
place and excluded."""
pending: list[tuple[bpy.types.Object, str]] = []
for obj in bpy.data.objects:
feature = cls._validated_editing_feature(obj)
if feature is not None:
pending.append((obj, feature.finish_op))
return pending
@classmethod
def run_bim_op(cls, bl_idname: str) -> None:
"""Invoke a ``bim.*`` operator by ``bl_idname``.
Asserts the operator is a ``tool.Ifc.Operator`` subclass bypassing
that wrap would mutate IFC outside Bonsai's transaction system."""
verb = bl_idname.removeprefix("bim.")
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator):
raise RuntimeError(
f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
)
getattr(bpy.ops.bim, verb)()
@classmethod
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
"""Run ``finish_op`` scoped to ``obj`` alone. Returns False (with
traceback printed) if the operator raised.
Both ``temp_override`` and ``view_layer.objects.active`` are set:
``temp_override`` does not rebind ``objects.active``, and some finish
operators read it directly."""
view_layer = bpy.context.view_layer
original_active = view_layer.objects.active
try:
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
view_layer.objects.active = obj
try:
cls.run_bim_op(finish_op)
return True
except Exception:
logger.warning(
"commit of %r via %s failed",
obj.name,
finish_op,
exc_info=True,
)
return False
finally:
view_layer.objects.active = original_active
@classmethod
def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]:
"""Run each pending draft's finish operator scoped to its object.
A per-object failure does not abort the loop remaining drafts
still flush, otherwise the auto-commit would ship the exact silent
desync it exists to prevent."""
committed = 0
failed: list[bpy.types.Object] = []
for obj, finish_op in cls.get_pending_edits():
if cls.commit_object_draft(obj, finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def commit_pending_edits_for_selection(
cls, names: Optional[tuple[str, ...]] = None
) -> tuple[int, list[bpy.types.Object]]:
"""Selection-scoped variant. ``names`` filters which registry entries
to consider; ``None`` considers every type."""
committed = 0
failed: list[bpy.types.Object] = []
for obj in tool.Blender.get_selected_objects():
feature = cls._validated_editing_feature(obj)
if feature is None:
continue
if names is not None and feature.name not in names:
continue
if cls.commit_object_draft(obj, feature.finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def _assert_predicates_registered(cls) -> None:
"""Loud at addon-enable if any ``EDIT_TYPES`` entry has no matching
``is_<name>`` classmethod. Without this, a typo in the registry entry
produces a silent-False predicate that never matches every
parametric draft of that type bypasses save-flow auto-commit."""
missing = [feature.name for feature in cls.EDIT_TYPES if not callable(getattr(cls, f"is_{feature.name}", None))]
if missing:
raise RuntimeError(
f"tool.Parametric.EDIT_TYPES has entries with no is_<name> predicate: {missing}. "
f"Add `is_<name>(cls, element) -> bool` classmethods on tool.Parametric, "
f"or remove the entries from EDIT_TYPES."
)
@classmethod
def register_object_properties(cls, prop_module) -> None:
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
parametric type. Skips entries whose ``PropertyGroup`` is absent."""
cls._assert_predicates_registered()
for feature in cls.EDIT_TYPES:
prop_cls = getattr(prop_module, feature.props_attr, None)
if prop_cls is None:
continue
setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls))
@classmethod
def unregister_object_properties(cls) -> None:
for feature in cls.EDIT_TYPES:
if hasattr(bpy.types.Object, feature.props_attr):
delattr(bpy.types.Object, feature.props_attr)
@classmethod
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
inject the per-type ``GizmoPreferences<X>`` classes at the correct
point before ``ui.GizmoPreferences``, which references them via
``PointerProperty``."""
# FIXME(PR5): drop the per-feature loop once PR4 consolidates
# bim/ui.py to use a single shared GizmoPreferencesFeature class
# and rewrites GizmoPreferences accordingly. The shared-class
# branch is the forward-compat path; the per-feature loop keeps
# v0.8.0's bim/ui.py working until then.
out: list[type] = []
for feature in cls.EDIT_TYPES:
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
if gpref is not None:
out.append(gpref)
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
if shared is not None:
out.append(shared)
return out
# --- Feature-kind predicates ------------------------------------------------
# One predicate per registered parametric type. Each is total: accepts any
# IFC entity (or None), returns a bool, never raises. Predicates live with
# the registry rather than ``tool.Blender.Modifier`` because they ARE the
# registry contract — ``find_for_element`` and ``_validated_editing_feature``
# resolve them by name. Coupling them on the same class makes a typo at
# registration time an immediate AttributeError instead of a silent None
# predicate that never matches.
@classmethod
def is_array(cls, element: entity_instance) -> bool:
"""True if element is the PARENT of a Bonsai parametric array.
Array children also carry a ``BBIM_Array`` pset (their ``Parent``
field points back to the original), so checking pset presence alone
would falsely match them. The parent is distinguished by
``pset.Parent == element.GlobalId``."""
import ifcopenshell.util.element
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
return pset.get("Parent") == element.GlobalId
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Railing") is not None
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Roof") is not None
@classmethod
def is_window(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Window") is not None
@classmethod
def is_door(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Door") is not None
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
if element is None or not element.is_a("IfcWall"):
return False
return tool.Model.get_usage_type(element) == "LAYER2"
@classmethod
def is_path_connectable_wall(cls, element: entity_instance) -> bool:
"""An IfcWall that may participate in IfcRelConnectsPathElements joins —
either a LAYER2 parametric wall, or a fillet-corner wall whose body is
hand-built but whose axis still drives path connections.
Distinct from ``is_wall``: that predicate gates parametric edits that
would regenerate the body and flatten a curved fillet. Unjoin / join
gizmo polls and path-connection partner enumeration use this looser
predicate so fillet corners (which have no LAYER2 usage by spec) still
surface their join icons."""
if element is None or not element.is_a("IfcWall"):
return False
if tool.Model.get_usage_type(element) == "LAYER2":
return True
return cls.is_fillet_corner_wall(element)
@classmethod
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
marking it as a curved corner whose banana body is hand-built rather
than regenerated from the wall's axis + layer set."""
import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
@classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
@classmethod
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
@classmethod
def build_edit_lifecycle(
cls,
feature_name: str,
mixin: type,
labels: tuple[tuple[str, str], tuple[str, str], tuple[str, str]],
bl_options: Optional[set[str]] = None,
enable_extra_props: Optional[dict[str, Any]] = None,
enable_extra_kwargs: Optional[Callable[[Any], dict[str, Any]]] = None,
module_name: Optional[str] = None,
) -> tuple[type, type, type]:
"""Generate (Enable, Finish, Cancel) operator classes for a parametric type.
``mixin`` provides ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` (i.e. inherits from ``ParametricEditMixinBase`` or
a sibling). ``labels`` is ``((enable_label, enable_desc), )`` in
Enable / Finish / Cancel order.
``bl_idname`` and the Python class name come from the registry entry
``feature_name`` MUST already be in ``EDIT_TYPES``, otherwise a typo
produces an unregistered operator. Anchoring bl_idnames to the registry
eliminates the silent-mismatch failure mode where a hand-typed
``bl_idname = "bim.enable_editing_dor"`` produces a class that
``find_for_element`` never resolves to.
``enable_extra_props`` declares extra ``bpy.props.*`` descriptors to
attach to the Enable class only (e.g. array's ``item: IntProperty``
carrying the target layer index across redo). When set,
``enable_extra_kwargs`` must also be supplied: it receives the Enable
operator instance and returns a kwargs dict forwarded to
``_enable_targets`` so the mixin's enable phase sees the extras.
``module_name`` sets ``__module__`` on the generated classes pass
``__name__`` from the calling feature module so Blender's right-click
Edit Source resolves to the feature module rather than the factory
site. Defaults to the factory's module, which is sub-optimal for
debugging but harmless."""
import bonsai.tool as _tool # late import: tool/__init__.py wires this module last
feature = cls.find_by_name(feature_name)
if feature is None:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} not in EDIT_TYPES — add a "
f"ParametricObject entry before declaring its operators"
)
if not feature.supports_build_edit_lifecycle:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} has supports_build_edit_lifecycle=False — "
f"its edit lifecycle is bespoke. Either declare "
f"Enable/Finish/CancelEditing{_camel_case(feature_name)} as direct Operator "
f"subclasses, or flip the flag on the EDIT_TYPES entry if the type does fit "
f"the shared mixin contract."
)
if (enable_extra_props is None) != (enable_extra_kwargs is None):
raise RuntimeError(
f"build_edit_lifecycle({feature_name!r}): enable_extra_props and "
f"enable_extra_kwargs must be supplied together — extras with no "
f"kwargs builder are unreachable, kwargs with no extras have nothing to forward"
)
options = bl_options if bl_options is not None else {"REGISTER", "UNDO"}
base_classes = (mixin, bpy.types.Operator, _tool.Ifc.Operator)
capitalised = _camel_case(feature_name)
def _build(
action: str, bl_idname: str, label: str, desc: str, target_method: str, extras: Optional[dict]
) -> type:
if extras and target_method == "_enable_targets":
assert enable_extra_kwargs is not None
kwargs_builder = enable_extra_kwargs
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context, **kwargs_builder(self))
else:
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context)
attrs: dict[str, Any] = {
"bl_idname": bl_idname,
"bl_label": label,
"bl_description": desc,
"bl_options": options,
"_execute": _execute,
}
if module_name is not None:
attrs["__module__"] = module_name
if extras:
# Blender's PropertyGroup machinery reads __annotations__ for bpy.props descriptors.
attrs["__annotations__"] = dict(extras)
return type(f"{action}Editing{capitalised}", base_classes, attrs)
return (
_build("Enable", feature.enable_op, labels[0][0], labels[0][1], "_enable_targets", enable_extra_props),
_build("Finish", feature.finish_op, labels[1][0], labels[1][1], "_finish_targets", None),
_build("Cancel", feature.cancel_op, labels[2][0], labels[2][1], "_cancel_targets", None),
)
_edit_type_names = [entry.name for entry in Parametric.EDIT_TYPES]
if len(set(_edit_type_names)) != len(_edit_type_names):
raise RuntimeError(
f"EDIT_TYPES name collision: {_edit_type_names}. Each name is the primary key "
f"for derived bl_idnames, BIM<Name>Properties attributes, is_<name> predicates, "
f"and the uppercase constant — a duplicate silently shadows the first entry."
)
del _edit_type_names
# Bind every registered ParametricObject as an uppercase class attribute so
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
# registry entry renames the constant; a typo at the call site surfaces as
# AttributeError at module load.
for _entry in Parametric.EDIT_TYPES:
setattr(Parametric, _entry.name.upper(), _entry)
del _entry
-30
View File
@@ -18,12 +18,10 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.attribute
import ifcopenshell.util.element
@@ -76,34 +74,6 @@ class Pset(bonsai.core.tool.Pset):
if pset:
return tool.Ifc.get().by_id(pset["id"])
@classmethod
def upsert_pset(
cls,
element: ifcopenshell.entity_instance,
pset_name: str,
properties: dict[str, Any],
) -> ifcopenshell.entity_instance:
"""Get or create ``pset_name`` on ``element``, write ``properties``, return the pset.
Centralises the get-element-pset add-pset-if-missing edit-pset idiom."""
ifc_file = tool.Ifc.get()
pset = cls.get_element_pset(element, pset_name)
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=pset_name)
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=properties)
return pset
@classmethod
def write_bbim_data(
cls,
element: ifcopenshell.entity_instance,
pset_name: str,
data: dict[str, Any],
) -> ifcopenshell.entity_instance:
"""Get or create the BBIM_<Type> pset and write ``data`` as the IfcText-serialised
JSON ``Data`` property. Canonical writer for parametric-modifier pset state."""
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
return cls.upsert_pset(element, pset_name, {"Data": data_text})
@classmethod
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
if obj_type == "Object":
+49 -145
View File
@@ -373,42 +373,26 @@ class Raycast(bonsai.core.tool.Raycast):
except:
loc = Vector((0, 0, 0))
verts_2d = [
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
] # Numpy version is worst in performance
snap_obj._ensure_bvh()
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
)
# Collect edges from intersected BVH boxes
edges = []
for it in intersected:
edges.extend(it.edges)
edges = set(edges)
# Build only the vertices indices that belong to these edges
verts_idx: set[int] = set()
for e in edges:
ev = snap_obj.obj.data.edges[e].vertices
verts_idx.add(ev[0])
verts_idx.add(ev[1])
# Lazily project only the needed vertices to 2D screen space
verts_2d: dict[int, Vector] = {}
for idx in verts_idx:
v2d = view3d_utils.location_3d_to_region_2d(
region, rv3d, snap_obj.verts_3d[idx]
)
if v2d is not None:
verts_2d[idx] = v2d
edge_verts = {}
for e in edges:
verts_idx = snap_obj.obj.data.edges[e].vertices
v1 = snap_obj.verts_3d[verts_idx[0]]
v2 = snap_obj.verts_3d[verts_idx[1]]
v1_2d = verts_2d.get(verts_idx[0])
v2_2d = verts_2d.get(verts_idx[1])
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
verts = snap_obj.obj.data.vertices
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
v1_2d = verts_2d[verts_idx[0]]
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
v2_2d = verts_2d[verts_idx[1]]
if (v1_2d is None) ^ (v2_2d is None):
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
if v1_2d is None:
@@ -420,16 +404,10 @@ class Raycast(bonsai.core.tool.Raycast):
snap_threshold = 10.0
# Check all vertices for proximity to mouse position.
# Re-use the 2D projections already computed for edge endpoints.
for i, v3d in enumerate(snap_obj.verts_3d):
if i in verts_2d:
v2d = verts_2d[i]
else:
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d)
if v2d is None:
continue
distance = (Vector(mouse_pos) - v2d).length
for i, point in enumerate(verts_2d):
if not point:
continue
distance = (Vector(mouse_pos) - point).length
if distance <= snap_threshold:
snap_point = {
"object": snap_obj.obj,
@@ -821,30 +799,6 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def process_wireframe_snap_obj(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
snap_obj,
ray_origin: Vector,
closest_snaps: list,
):
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
hit_obj = None
hit = None
if snap_points:
closest_length_squared = float("inf")
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if length < closest_length_squared:
closest_length_squared = length
hit = point["point"]
hit_obj = point["object"]
return hit_obj, hit
@classmethod
def ray_cast_and_get_closest_to_camera_snaps(
cls,
@@ -859,45 +813,35 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
space = context.space_data
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
)
closest_snaps = []
hit = None
if not xray_mode and objs_to_raycast:
# Non-xray - only the closest solid object's Face snap is kept by
# the caller (detect_snapping_points). Process solids in distance
# order and stop at the first hit to minimise raycasts.
wireframe_objs = []
solid_objs = []
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
wireframe_objs.append(snap_obj)
else:
solid_objs.append(snap_obj)
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
closest_wf_hit = None
closest_wf_length_squared = 1.0
closest_wf_point = None
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if closest_wf_hit is None or length < closest_wf_length_squared:
closest_wf_length_squared = length
closest_wf_hit = point["point"]
closest_wf_point = point
# Rough distance - object origin to ray origin
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
if closest_wf_point:
hit_obj = closest_wf_point["object"]
hit = closest_wf_point["point"]
face_index = None
# Process wireframe objects first (all of them, always collected)
for snap_obj in wireframe_objs:
hit_obj, hit = cls.process_wireframe_snap_obj(
context, event, snap_obj, ray_origin, closest_snaps
)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = None
# Process solid objects in distance order, stop at first hit
for snap_obj in solid_objs:
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
@@ -911,47 +855,14 @@ class Raycast(bonsai.core.tool.Raycast):
}
closest_snaps.append(snap_point)
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
break
else:
# Xray mode - process all objects (all snaps are kept by the caller)
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
hit_obj, hit = cls.process_wireframe_snap_obj(
context, event, snap_obj, ray_origin, closest_snaps
)
face_index = None
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": hit_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Label snaps from the closest object
if closest_obj is not None:
@@ -1025,19 +936,12 @@ class SnapObj:
def __init__(self, obj: bpy.types.Object):
self.__class__.all.append(self)
self.obj = obj
self.root = None
self._bvh_built = False
self.root = self._create_root_node()
self.root.edges = [e.index for e in obj.data.edges]
self.split_box(self.root, 0)
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.snap_points = []
def _ensure_bvh(self):
if self._bvh_built:
return
self.root = self._create_root_node()
self.root.edges = [e.index for e in self.obj.data.edges]
self.split_box(self.root, 0)
self._bvh_built = True
def __clear_all__():
for instance in SnapObj.all:
del instance
-74
View File
@@ -1,74 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions.
Exposes ``read_geometry``: a single live read of the parametric attributes
(extrusion depth and slope) that drive icon placement and dimension display
on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay
declarative they get a dict, not an IFC walk."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
import ifcopenshell.util.unit
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy
class SlabGeometry(TypedDict):
depth: float
x_angle: float
class Slab(bonsai.core.tool.Slab):
@classmethod
def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None:
"""Live-read slab parametric geometry as a dict, or ``None`` if the
object is not a LAYER3 extruded slab.
Returned keys (all SI units): ``depth`` (extrusion thickness along the
slab's local Z), ``x_angle`` (slope in radians; zero for level slabs).
The slope is encoded in ``obj.matrix_world`` as a post-rotation, so
callers projecting world points into slab-local space via
``mw.inverted()`` will see a level frame whose Z runs along the slab
thickness ``x_angle`` is reported for callers that need the slope
as a scalar but is already applied by the placement."""
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_slab(element):
return None
representation = tool.Geometry.get_body_representation(element)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
x_angle = tool.Model.get_existing_x_angle(extrusion)
return {
"depth": extrusion.Depth * unit_scale,
"x_angle": x_angle,
}
-26
View File
@@ -90,32 +90,6 @@ class Spatial(bonsai.core.tool.Spatial):
break
return element
@classmethod
def get_host_element(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The building element that hosts a filling (door/window) via the
standard ``FillsVoids RelatingOpeningElement VoidsElements
RelatingBuildingElement`` chain, with safety guards at each hop.
Returns ``None`` if any link is missing, or if the given entity is
not a fillable type (no ``FillsVoids`` inverse).
For the wall-only case (gizmos that only make sense on walls), use
`get_host_wall` which adds an ``IfcWall`` type filter on top of this."""
if not getattr(filling, "FillsVoids", None):
return None
opening = filling.FillsVoids[0].RelatingOpeningElement
if not opening.VoidsElements:
return None
return opening.VoidsElements[0].RelatingBuildingElement
@classmethod
def get_host_wall(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The ``IfcWall`` that hosts a filling (door/window), or ``None``.
Walls only fillings hosted in slabs / roofs / arbitrary elements
produce ``None`` so wall-offset callers stay opted out cleanly."""
host = cls.get_host_element(filling)
return host if host and host.is_a("IfcWall") else None
@classmethod
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
if tool.Ifc.get_schema() == "IFC2X3":
-5
View File
@@ -203,11 +203,6 @@ class Style(bonsai.core.tool.Style):
available_props = props.bl_rna.properties.keys()
for prop_blender, prop_ifc in STYLE_PROPS_MAP.items():
null_prop_name = f"is_{prop_blender}_null"
if null_prop_name in available_props and getattr(props, null_prop_name):
surface_style_data[prop_ifc] = None
continue
class_prop_name = f"{prop_blender}_class"
# get detailed color properties if available
+22 -118
View File
@@ -19,7 +19,6 @@
from __future__ import annotations
import re
from collections import deque
from enum import Enum
from typing import TYPE_CHECKING, Any, Optional, Union
@@ -27,7 +26,6 @@ import bpy
import ifcopenshell.api.geometry
import ifcopenshell.api.system
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.system
from mathutils import Matrix, Vector
@@ -37,29 +35,12 @@ import bonsai.core.root
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
# Data-class imports from ``bonsai.bim.module.system.data`` are function-local:
# a top-level import would trigger a partial-init cycle through tool.Ifc.Operator.
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
if TYPE_CHECKING:
from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties
_DIRECTION_FROM_FLOW_PAIR: dict[tuple[str, str], str] = {
("SOURCE", "SINK"): "SOURCE",
("SINK", "SOURCE"): "SINK",
("SOURCEANDSINK", "SOURCEANDSINK"): "SOURCEANDSINK",
}
def direction_from_port_pair(port_a: ifcopenshell.entity_instance, port_b: ifcopenshell.entity_instance) -> str:
"""Derive the ``direction`` arg for ``ifcopenshell.api.system.connect_port``
from each port's ``FlowDirection``. Returns ``NOTDEFINED`` for non-canonical pairs."""
a = getattr(port_a, "FlowDirection", None) or "NOTDEFINED"
b = getattr(port_b, "FlowDirection", None) or "NOTDEFINED"
return _DIRECTION_FROM_FLOW_PAIR.get((a, b), "NOTDEFINED")
class System(bonsai.core.tool.System):
@classmethod
def get_system_props(cls) -> BIMSystemProperties:
@@ -100,7 +81,7 @@ class System(bonsai.core.tool.System):
# make sure obj.dimensions and .matrix_world has valid data
bpy.context.view_layer.update()
# need to make sure .ObjectPlacement is also updated when we're going to add ports
tool.Geometry.commit_placement_if_moved(obj)
tool.Model.sync_object_ifc_position(obj)
mep_element = tool.Ifc.get_entity(obj)
bbox = tool.Blender.get_object_bounding_box(obj)
@@ -181,12 +162,12 @@ class System(bonsai.core.tool.System):
return ifcopenshell.util.system.get_ports(element)
@classmethod
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
if tool.Ifc.get_schema() == "IFC2X3":
rel = port.ContainedIn[0] if port.ContainedIn else None
return rel.RelatedElement if rel else None
rel = port.Nests[0] if port.Nests else None
return rel.RelatingObject if rel else None
element = port.ContainedIn[0].RelatedElement
else:
element = port.Nests[0].RelatingObject
return element
@classmethod
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
@@ -299,42 +280,31 @@ class System(bonsai.core.tool.System):
system_props = cls.get_system_props()
return tool.Ifc.get_entity_by_id(system_props.active_system_id)
# Decoration-data cache, keyed on (decorator_cache_token, id(decorated_elements_set)).
_decoration_data_cache_key: tuple | None = None
_decoration_data_cache: dict[str, Any] | None = None
@classmethod
def get_decoration_data(cls) -> dict[str, Any]:
from bonsai.bim.decorator_cache import get_decorator_cache_token
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
if not ObjectSystemData.is_loaded:
ObjectSystemData.load()
if not SystemDecorationData.is_loaded:
SystemDecorationData.load()
token = get_decorator_cache_token()
key = (token, id(SystemDecorationData.data["decorated_elements"]))
if key == cls._decoration_data_cache_key and cls._decoration_data_cache is not None:
return cls._decoration_data_cache
result = cls._build_decoration_data()
cls._decoration_data_cache_key = key
cls._decoration_data_cache = result
return result
@classmethod
def _build_decoration_data(cls) -> dict[str, Any]:
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
all_vertices = []
preview_edges = []
special_vertices = []
selected_edges = []
selected_vertices = []
view3d_space = tool.Blender.get_viewport_context()["space_data"].region_3d
viewport_matrix = view3d_space.view_matrix.inverted()
viewport_y_axis = viewport_matrix.col[1].to_3d().normalized()
camera_pos = viewport_matrix.translation
dir_to_camera = lambda x: (camera_pos - x).normalized()
def most_aligned_vector(a, vectors):
return max(vectors, key=lambda v: abs(a.dot(v)))
start_vert_i = 0
if not ObjectSystemData.is_loaded:
ObjectSystemData.load()
if not SystemDecorationData.is_loaded:
SystemDecorationData.load()
class FlowDirection(Enum):
BACKWARD = -1
FORWARD = 1
@@ -488,72 +458,6 @@ class System(bonsai.core.tool.System):
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
@classmethod
def walk_connected_mep_elements(
cls, start_element: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
"""Return all MEP elements reachable from ``start_element`` via
``IfcRelConnectsPorts`` in either direction, in BFS order with
``start_element`` first.
Only ``IfcFlowSegment`` and ``IfcFlowFitting`` instances are
returned; non-MEP neighbours reached via a fitting's port are
traversed but not collected.
"""
if not cls.is_mep_element(start_element):
return []
result: list[ifcopenshell.entity_instance] = []
visited: set[int] = set()
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
while queue:
element = queue.popleft()
if element.id() in visited:
continue
visited.add(element.id())
if not cls.is_mep_element(element):
continue
result.append(element)
for port in cls.get_ports(element):
connected_port = cls.get_connected_port(port)
if connected_port is None:
continue
neighbor = cls.get_port_relating_element(connected_port)
if neighbor is None or neighbor.id() in visited:
continue
queue.append(neighbor)
return result
@classmethod
def get_port_world_position(cls, port: ifcopenshell.entity_instance) -> Vector:
"""World-space position of an ``IfcDistributionPort``.
Follows the parent element's live ``matrix_world`` when available so
an uncommitted rotation doesn't drift from its ports; falls back to
the raw IFC placement otherwise."""
placement = getattr(port, "ObjectPlacement", None)
if placement is None:
return Vector((0.0, 0.0, 0.0))
port_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(placement).tolist())
parent_element = cls.get_port_relating_element(port)
if parent_element is None:
return Vector(port_ifc_matrix.translation)
parent_obj = tool.Ifc.get_object(parent_element)
if parent_obj is None:
return Vector(port_ifc_matrix.translation)
parent_placement = getattr(parent_element, "ObjectPlacement", None)
if parent_placement is None:
return Vector(port_ifc_matrix.translation)
parent_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(parent_placement).tolist())
try:
port_local_to_parent = parent_ifc_matrix.inverted() @ port_ifc_matrix
except ValueError:
return Vector(port_ifc_matrix.translation)
return (parent_obj.matrix_world @ port_local_to_parent).translation
@classmethod
def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
if not element.HasControlElements:
+2 -2
View File
@@ -199,8 +199,8 @@ class Unit(bonsai.core.tool.Unit):
if inches is None:
inches = 0
# If feet is negative (including -0), inches should also be negative (subtractive)
if math.copysign(1, feet) < 0:
# If feet is negative, inches should also be negative (subtractive)
if feet < 0:
inches = -inches
# Convert to meters
-327
View File
@@ -1,327 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Side-effect-free wall helpers — IFC reads and wall-axis geometry, callable from
gizmo lambdas without loading the wall's draft props. The world-space geometry helpers
are pure-math wrappers over ``bonsai.core.model``."""
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING, TypedDict
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.unit
from mathutils import Vector
import bonsai.core.model
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy
class WallGeometry(TypedDict):
anchor_x: float
length: float
height: float
x_angle: float
thickness: float
offset: float
class Wall(bonsai.core.tool.Wall):
@classmethod
def get_length_and_height(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
"""SI length and vertical height of a LAYER2 extruded wall, or ``None`` for
non-parametric bodies (sweeps, brep, non-extrusion booleans)."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
x_angle = tool.Model.get_existing_x_angle(extrusion)
return bonsai.core.model.length_and_height_from_extrusion(
extrusion_depth=extrusion.Depth,
x_angle=x_angle,
reference_line_x_extent=p2[0] - p1[0],
unit_scale=unit_scale,
)
@classmethod
def get_axis_local_extent(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
"""``(min_x, max_x)`` of the wall's IFC reference line in wall-local SI metres,
or ``None``. Anchors wall-edge gizmos at IFC-authoritative ends ``obj.bound_box``
would drift on trimmed walls or walls with end openings."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
x1, x2 = p1[0] * unit_scale, p2[0] * unit_scale
return (min(x1, x2), max(x1, x2))
@classmethod
def get_x_angle(cls, wall: ifcopenshell.entity_instance) -> float | None:
"""Slanted-extrusion angle (radians) of a LAYER2 wall, zero for vertical walls,
``None`` for non-parametric bodies. Callers that assume wall-local Z == world Z
must gate on this being zero."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
return tool.Model.get_existing_x_angle(extrusion)
@classmethod
def read_geometry(cls, obj: bpy.types.Object) -> WallGeometry | None:
"""Live wall geometry from IFC in SI metres/radians, or ``None`` for
non-path-connectable walls. Shared by gizmo positioning and draft
initialisation. Fillet-corner walls carry their chord axis as the
reference line and report zero thickness / offset (material was
unassigned at construction); callers that need a layer-driven thickness
must gate on ``tool.Parametric.is_wall`` upstream."""
element = tool.Ifc.get_entity(obj)
if not element or not tool.Parametric.is_path_connectable_wall(element):
return None
representation = tool.Geometry.get_body_representation(element)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
layer_params = tool.Model.get_material_layer_parameters(element)
x_angle = tool.Model.get_existing_x_angle(extrusion)
return {
"anchor_x": p1[0] * unit_scale,
"length": (p2[0] - p1[0]) * unit_scale,
"height": bonsai.core.model.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle),
"x_angle": x_angle,
"thickness": layer_params["thickness"],
"offset": layer_params["offset"],
}
@classmethod
def collinear_boundary_world(cls, seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector:
"""World-space midpoint of the closest endpoint pair across two wall axis segments —
the anchor for Merge/Unjoin gizmos on collinear or already-joined walls."""
return Vector(
bonsai.core.model.closest_endpoint_midpoint(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
)
)
@classmethod
def path_connection_location_world(
cls,
seg_self: tuple[Vector, Vector],
self_conn_type: str,
seg_other: tuple[Vector, Vector],
other_conn_type: str,
parallel_threshold: float = bonsai.core.model.PARALLEL_DOT_THRESHOLD,
) -> Vector:
"""World-space physical join point of an ``IfcRelConnectsPathElements`` — an
endpoint for end-connected walls, the axis intersection for ATPATH junctions."""
return Vector(
bonsai.core.model.compute_path_connection_location(
(tuple(seg_self[0]), tuple(seg_self[1])),
self_conn_type,
(tuple(seg_other[0]), tuple(seg_other[1])),
other_conn_type,
parallel_threshold,
)
)
@classmethod
def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None:
"""``None`` if the wall is parametrically editable, else a user-facing string naming
the specific gap so the user can fix the precise blocker."""
element = tool.Ifc.get_entity(obj)
if not element:
return "Object is not an IFC element."
if not element.is_a("IfcWall"):
return f"Object is an {element.is_a()}, not an IfcWall."
if tool.Model.get_usage_type(element) != "LAYER2":
return (
"Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)."
)
representation = tool.Geometry.get_body_representation(element)
if not representation:
return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
if not tool.Model.get_extrusion(representation):
return (
"Wall body is not an IfcExtrudedAreaSolid "
"(e.g. a brep mesh or boolean result without a base extrusion)."
)
return None
@classmethod
def has_layer2_usage(cls, wall: ifcopenshell.entity_instance) -> bool:
"""True iff ``wall`` is a LAYER2 parametric wall (has ``IfcMaterialLayerSetUsage``
with ``LayerSetDirection == AXIS2``). Required by every parametric wall edit
non-LAYER2 walls (brep / freeform bodies) cannot be driven by axis + thickness."""
return tool.Model.get_usage_type(wall) == "LAYER2"
@classmethod
def is_straight_axis(cls, wall: ifcopenshell.entity_instance) -> bool:
"""True iff the wall's Axis representation is a single straight line segment.
Curved-axis walls (e.g. a fillet corner inserted between two straight walls)
report ``False`` so callers gate them out of operations that assume a straight
reference line. The check inspects the ``Plan/Axis/GRAPH_VIEW`` representation
when present; falls back to True when no Axis representation exists (the
``Body`` extrusion alone is implicitly straight)."""
axis_rep = ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW")
if axis_rep is None or not axis_rep.Items:
return True
for item in axis_rep.Items:
if item.is_a("IfcPolyline"):
if len(item.Points) != 2:
return False
elif item.is_a("IfcIndexedPolyCurve"):
# An ``IfcIndexedPolyCurve`` is straight only when (a) its
# ``Points`` list holds exactly two points and (b) it has no
# ``Segments`` or only ``IfcLineIndex`` segments. Any ``IfcArcIndex``
# makes it curved.
segments = getattr(item, "Segments", None)
if segments:
for seg in segments:
if seg.is_a("IfcArcIndex"):
return False
point_list = item.Points
point_coords = getattr(point_list, "CoordList", None) if point_list else None
if point_coords and len(point_coords) > 2:
return False
else:
# Trimmed curve, composite curve, B-spline — definitely curved.
return False
return True
@classmethod
def get_world_reference_line(cls, obj: bpy.types.Object) -> tuple[Vector, Vector] | None:
"""World-space endpoints of the wall's IFC reference line, in Blender units.
Returns ``(p1, p2)`` as 3D vectors with the wall's local Z preserved.
Returns ``None`` when the wall has no IFC element or no IFC Axis
representation. Anchors to the IFC reference line, not the mesh bound
box, so it stays correct when the mesh is stale or trimmed past the
IFC axis endpoints."""
element = tool.Ifc.get_entity(obj)
if element is None or not tool.Geometry.has_axis_representation(element):
return None
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
local_p1 = Vector((p1[0] * unit_scale, p1[1] * unit_scale, 0.0))
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
@classmethod
def walk_connected_walls(
cls,
start_element: ifcopenshell.entity_instance,
node_cap: int = 5000,
) -> list[ifcopenshell.entity_instance]:
"""BFS over ``IfcRelConnectsPathElements`` from ``start_element``.
Returns every ``IfcWall`` reachable in either direction (relating /
related side of the relation) in BFS order with ``start_element``
first. Stops when ``node_cap`` walls have been visited so a corrupt
or massive network can't lock up a draw callback. Non-wall path
elements (e.g. ``IfcRoof``, ``IfcSlab``) are traversed but not
collected they may bridge two disjoint wall runs.
Mirror of ``tool.System.walk_connected_mep_elements``."""
if not start_element.is_a("IfcWall"):
return []
result: list[ifcopenshell.entity_instance] = []
visited: set[int] = set()
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
while queue and len(visited) < node_cap:
element = queue.popleft()
if element.id() in visited:
continue
visited.add(element.id())
if element.is_a("IfcWall"):
result.append(element)
# ``ConnectedTo`` / ``ConnectedFrom`` are the IFC inverse
# attributes that expose the relations where this element
# is the relating / related side respectively.
for rel in getattr(element, "ConnectedTo", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
neighbor = rel.RelatedElement
if neighbor is not None and neighbor.id() not in visited:
queue.append(neighbor)
for rel in getattr(element, "ConnectedFrom", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
neighbor = rel.RelatingElement
if neighbor is not None and neighbor.id() not in visited:
queue.append(neighbor)
return result
@classmethod
def compute_wall_fillet_geometry(
cls,
wall_a_obj: bpy.types.Object,
wall_b_obj: bpy.types.Object,
radius: float,
arc_resolution: int = bonsai.core.model.FILLET_DEFAULT_ARC_RESOLUTION,
) -> dict | None:
"""Compute fillet geometry between two walls in world space.
Returns a dict augmented with ``profile_thickness`` and ``height`` from
the active (A) wall's LAYER2 parameters, plus ``wall_type_id`` and
``x_angle``. Returns ``None`` when either wall lacks a reference line
or LAYER2 usage."""
axis_a = cls.get_world_reference_line(wall_a_obj)
axis_b = cls.get_world_reference_line(wall_b_obj)
if axis_a is None or axis_b is None:
return None
wall_a = tool.Ifc.get_entity(wall_a_obj)
if wall_a is None or not cls.has_layer2_usage(wall_a):
return None
seg_a = ((axis_a[0].x, axis_a[0].y, axis_a[0].z), (axis_a[1].x, axis_a[1].y, axis_a[1].z))
seg_b = ((axis_b[0].x, axis_b[0].y, axis_b[0].z), (axis_b[1].x, axis_b[1].y, axis_b[1].z))
result = bonsai.core.model.compute_fillet_polylines(seg_a, seg_b, radius, arc_resolution)
layers = tool.Model.get_material_layer_parameters(wall_a)
length_height = cls.get_length_and_height(wall_a)
wall_type = ifcopenshell.util.element.get_type(wall_a)
result.update(
{
"profile_thickness": layers["thickness"],
"profile_offset": layers["offset"],
"height": length_height[1] if length_height else None,
"x_angle": cls.get_x_angle(wall_a) or 0.0,
"wall_type_id": wall_type.id() if wall_type else None,
}
)
return result
@@ -51,62 +51,6 @@ To use these tools:
2. Use the appropriate shortcut or select the tool from the top bar.
3. Follow the on-screen prompts or adjust parameters as needed.
Interactive Parametric Editing
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Selected walls expose an in-viewport parametric edit mode that mirrors the door /
window / stair pen-icon UI:
1. Select a single wall. A pen (Edit Wall) icon appears next to the wall in the
3D viewport, and a matching ``Edit Wall`` button is available in the
``Parametric Geometry`` tab of the N panel.
2. Click the pen icon (or the panel button) to enter edit mode. Dimension
gizmos for length, height, slope (x-angle) and the layer offset baseline
appear around the wall.
3. Drag any handle to update the value. Dragging only modifies the in-progress
draft — the IFC file is not touched until you commit, so dragging a length
handle through many intermediate values produces zero extra IFC entities.
4. Click the green ✓ icon to commit; click the red ✗ to discard. Pressing the
✓ icon on a wall that hasn't been dragged is a true byte-identical no-op —
the IFC file is unchanged.
While editing, additional gizmos surface based on context:
- **Cycle Baseline**: cycles the layer offset baseline (Exterior → Centreline →
Interior). Shift+click cycles in reverse.
- **3D-cursor scissors**: appears when the 3D cursor sits on the wall axis;
clicking splits the wall at the cursor's projected X.
- **3D-cursor extend (horizontal)**: appears when the 3D cursor sits beyond the
wall axis; clicking extends the wall to the cursor's projected X.
- **3D-cursor extend (vertical)**: appears when the 3D cursor sits above /
below the wall; clicking extends the wall's height to the cursor's Z.
- **Rotate 90°**: rotates the wall around its Z axis.
- **Show / hide openings**: toggles opening fill visibility (doors and windows).
When two walls are selected, the gizmo switches to a state-aware icon at their
common point:
- Already joined → an Unjoin icon at the shared corner.
- Collinear (same axis line) → a Merge icon at the boundary midpoint.
- Joinable corner → a Join icon at the floor + an Extend-To-Wall icon at the
active wall's top.
When a wall and a slab (LAYER3 element) are selected, an Extend-Vertically icon
appears at the wall's origin / slab elevation; clicking dispatches
``bim.extend_walls_to_underside``.
When a wall and a non-wall, non-slab object are selected, an Add-Opening icon
appears above the wall at the other object's projected X.
Auto-commit on save
~~~~~~~~~~~~~~~~~~~
Pressing Ctrl+S (or running ``bim.save_project``) while any wall is mid-edit
flushes every pending parametric draft first — the same Apply-Wall-Edits the ✓
icon performs, scoped per wall. The IFC saved on disk reflects the values the
user dragged, not the snapshot taken when edit mode was entered. Each commit
produces its own undo entry, so Ctrl+Z walks back through commits individually.
Aligning Walls
^^^^^^^^^^^^^^
@@ -61,8 +61,6 @@ When Blender ships with a new Python version:
- What to update
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_BLENDER_PY_VERSION``
* - ``.github/scripts/publish-bonsai-releases.py``
- ``CURRENT_PYTHON_VERSION``
* - ``src/bonsai/Makefile``
- ``SUPPORTED_PYVERSIONS``
* - ``src/bonsai/scripts/dev_environment.py``
@@ -75,18 +73,6 @@ Notes:
- Typically all packages are released at once using the same version schema
- The ``README.md`` badges can serve as a visual reference for what versions have been released
- Corrective Release (if needed after a standard release):
- Create a new branch from the release tag (e.g., from the ``ifcopenshell-0.8.5`` tag)
- Update ``VERSION`` with the ``-post1`` suffix (e.g., ``0.8.5-post1``, **not** ``.post1``)
- The hyphen is required for semantic versioning compliance; Blender will not process ``.post1`` suffixes correctly
- Follow the standard release process for the corrective version
- Multiple Blender Python Versions:
- Blender does not allow multiple builds for the same platform with different Python versions (e.g., cannot have both ``bonsai_py311-0.8.5-windows-x64.zip`` and ``bonsai_py313-0.8.5-windows-x64.zip``)
- Workaround: publish different Python versions as different extension versions (e.g., py313 as ``0.8.5`` and py311 as ``0.8.5-post1``)
- Set the maximum Blender version on the Blender extensions platform UI to prevent conflicts (e.g., set max version ``5.1.0`` for ``0.8.5-post1``, which restricts it to versions below 5.1.0)
Things to update:
@@ -110,13 +96,10 @@ Things to update:
- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases
- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester <https://pypi.org/project/ifctester/>`_ to PyPI
- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels <https://github.com/IfcOpenShell/wasm-wheels>`_
- ``.github/workflows/publish-bonsai-releases.yml`` - publish Bonsai Blender extension to `Blender extensions platform <https://extensions.blender.org/add-ons/bonsai/>`_
- ❗ Requires ``BLENDER_EXTENSIONS_TOKEN`` secret to be set - ❗ not yet configured
- Release Bonsai Blender extension - zip files from ci-bonsai.yml releases should be uploaded manually to `Blender extensions platform <https://extensions.blender.org/add-ons/bonsai/>`_
- Publishing documentation and websites (see `website <https://github.com/IfcOpenShell/website>`_ repository):
- `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org <https://docs.ifcopenshell.org>`_ (`ifcopenshell_org_docs <https://github.com/IfcOpenShell/ifcopenshell_org_docs>`_ repo)
- `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org <https://docs.bonsaibim.org>`_ (`bonsaibim_org_docs <https://github.com/IfcOpenShell/bonsaibim_org_docs>`_ repo)
- `publish-websites.yml` - publishes `bonsaibim.org <https://bonsaibim.org>`_ (`bonsaibim_org_static_html <https://github.com/IfcOpenShell/bonsaibim_org_static_html>`_ repo) and `ifcopenshell.org <https://ifcopenshell.org>`_ (`ifcopenshell_org_static_html <https://github.com/IfcOpenShell/ifcopenshell_org_static_html>`_ repo)
- `main.yml` - publishes `bonsaibim.org <https://bonsaibim.org>`_ (`bonsaibim_org_static_html <https://github.com/IfcOpenShell/bonsaibim_org_static_html>`_ repo) and `ifcopenshell.org <https://ifcopenshell.org>`_ (`ifcopenshell_org_static_html <https://github.com/IfcOpenShell/ifcopenshell_org_static_html>`_ repo)
- ``VERSION`` to the release version - **UPDATE THIS LAST** as all workflows above typically depend on it to set the version correctly
@@ -58,7 +58,7 @@ Fields
Class** based on the IFC Schema version.
**Unit System**
Choose between metric and imperial units of measurement when creating a project. Project data is stored in this Unit System and displayed according to e.g. Length Unit, Area Unit, Volume Unit. Properly changing the Unit System after project creation requires conversion. See `Blender Manual : Scene Properties : Units <https://docs.blender.org/manual/en/latest/scene_layout/scene/properties.html#units>`_ for a description of changing the display units e.g. from Feet to Adaptive (enable Separate Units option) for Feet-and-Inches.
Choose between metric and imperial units of measurement when creating a project.
**Length Unit**
Depending on the unit system, choose the default unit to be used for all length measurements. Lengths are used for moving objects around in the 3D scene, as well as lengths, widths, height, and depth quantity take-off data.
+3 -31
View File
@@ -17,46 +17,18 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Requires pytest installed under blender.
Requires pytest installed under blender
Usage:
blender -b -P runpytest.py -- ARGS
Alternative (when the calling shell strips or reorders the ``--`` separator
before it reaches Blender observed with some PowerShell / wrapper-script
invocations on Windows): pass the same pytest args via the
``BONSAI_TEST_ARGS`` environment variable as a single shell-quoted string
and invoke without ``--``::
$env:BONSAI_TEST_ARGS = "test/bim/ -x -q"
blender -b -P runpytest.py
Usage: `blender -b -P runpytest.py -- ARGS`
"""
import os
import shlex
import sys
import pytest
argv = [__file__]
env_args = os.environ.get("BONSAI_TEST_ARGS", "")
if env_args:
# POSIX-style quoting works on all three OSes — env var values are
# literal strings (no shell evaluation when Python reads them), and
# POSIX quoting (``'foo "bar baz" qux'`` → three tokens, quotes stripped)
# matches what most docs and examples use.
argv += shlex.split(env_args)
# On the env-var path the args never appear in Blender's argv at all,
# so any pytest plugin that reads ``sys.argv`` directly (instead of
# going through pytest's API) would otherwise see only Blender's own
# ``-b -P runpytest.py`` and miss the test args entirely. Shadow argv
# so those plugins see the pytest-shaped view they expect.
sys.argv = list(argv)
elif "--" in sys.argv:
# The traditional path: Blender forwards everything after ``--`` to the
# script via ``sys.argv``. ``sys.argv`` is deliberately left as Blender
# set it — pre-existing behavior, preserved.
if "--" in sys.argv:
i = sys.argv.index("--")
argv += sys.argv[i + 1 :]
@@ -285,32 +285,6 @@ Scenario: Override duplicate move - without active IFC data
Then the object "Cube" exists
And the object "Cube.001" exists
Scenario: Override duplicate move - non-IFC objects inside an IFC project
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
When I duplicate the selected objects
Then the object "Cube" exists
And the object "Cube.001" exists
And the object "Cube.001" is selected
Scenario: Override duplicate move - mixed IFC and non-IFC selection
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I add a cube
And the object "IfcWall/Cube" is selected
And additionally the object "Cube" is selected
When I duplicate the selected objects
Then the object "IfcWall/Cube.001" exists
And the object "IfcWall/Cube.001" is selected
And the object "Cube.001" exists
And the object "Cube.001" is selected
Scenario: Override duplicate move - with active IFC data
Given an empty IFC project
And I add a cube
-124
View File
@@ -285,7 +285,6 @@ Scenario: Split a wall which has a flipped door
And the object "IfcWall/Wall" is selected
And I press "bim.hotkey(hotkey='S_K')"
Then the object "IfcDoor/Door" is at "8.01,0.1,0"
And the object "IfcWall/Wall.001" is filled by "IfcDoor/Door"
Scenario: Offset walls
Given an empty IFC project
@@ -674,129 +673,6 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e
And I press "bim.finish_editing_door()"
Then nothing happens
Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And I press "bim.enable_editing_door()"
And I set "active_object.BIMDoorProperties.overall_height" to "2.5"
Then "active_object.BIMDoorProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMDoorProperties.is_editing" is "False"
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "saved_height" equals "2.5"
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "post_save_height" equals "{pre_save_height}"
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Enabling and finishing a wall edit with no drag is a no-op
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And the variable "entity_count_before" is "len(list({ifc}))"
When I press "bim.enable_editing_wall()"
And I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
And "len(list({ifc}))" is "{entity_count_before}"
Scenario: Cancelling a wall edit clears is_editing
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
When I press "bim.cancel_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Wall parametric edit works on IFC2X3 projects
Given an empty IFC2X3 project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Rotate a wall 90° via bim.rotate_wall_90
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.rotate_wall_90()"
Then the object "IfcWall/Wall" dimensions are "1,0.1,3"
And the object "IfcWall/Wall" bottom left corner is at "0,0,0"
And the object "IfcWall/Wall" top right corner is at "-0.1,1,3"
Scenario: Splitting a wall with another wall mid-edit commits the pending edit first
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.split_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Create a door, undo and create a new door
Given an empty IFC project
And I prepare to undo
@@ -1,100 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression guard: overlapping distance gizmos must let the smaller one win.
When two ``GizmoDimension`` instances overlap on screen (e.g. a short dimension
nested inside a longer one along the same axis), the longer one's hit box fully
contains the shorter one's. Without a depth bias the longer one wins the GPU
select tie-break and the shorter one becomes unreachable.
``GizmoDimension.set_dimension_length`` writes ``select_bias = -dimension_length``
so the smaller one writes a higher (less-negative) bias and wins. The longer one
stays clickable at its exposed ends regardless of bias.
We call ``set_dimension_length`` as an unbound method on a ``SimpleNamespace``
fake ``self``. Its body only *writes* attributes (``_display_value``,
``_dimension_length``, ``select_bias``), so it doesn't need a real
``bpy.types.Gizmo`` instance those only exist inside a registered
``GizmoGroup`` and aren't constructible in a headless test."""
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import GizmoDimension
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_smaller_dimension_wins_select_bias():
small = SimpleNamespace()
large = SimpleNamespace()
GizmoDimension.set_dimension_length(small, 0.077)
GizmoDimension.set_dimension_length(large, 0.109)
assert small.select_bias > large.select_bias
@pytest.mark.parametrize(
"lengths",
[
[0.0, 0.05, 0.077, 0.109, 1.0, 5.0, 10.0],
[0.001, 0.5, 2.5, 100.0, 9999.0],
],
)
def test_select_bias_is_non_increasing_in_length(lengths):
"""A monotonic mapping is all Blender's GPU select needs to break the tie."""
biases = []
for length in lengths:
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, length)
biases.append(gizmo.select_bias)
for prev, curr in zip(biases, biases[1:]):
assert prev >= curr, f"select_bias must be non-increasing in length, got {biases}"
def test_negative_length_uses_absolute_value_for_bias():
"""Negative dimension values (e.g. inverted angles) clamp to abs() for hit-box scaling;
select_bias follows the same clamped magnitude so signed-direction gizmos still
obey the smaller-wins rule against their positive-sided peers."""
positive = SimpleNamespace()
negative = SimpleNamespace()
GizmoDimension.set_dimension_length(positive, 0.5)
GizmoDimension.set_dimension_length(negative, -0.5)
assert positive.select_bias == negative.select_bias
def test_nan_and_inf_length_falls_back_to_zero_bias():
"""Invalid inputs are coerced to 0.0 before the bias is written, so a malformed
update can't push a gizmo arbitrarily far forward or backward in the select buffer."""
import math
for bad in (math.nan, math.inf, -math.inf, "not a number"):
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, bad)
assert gizmo.select_bias == 0.0
@@ -1,54 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_text_formatter_defaults_to_none():
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0))
assert config.text_formatter is None
def test_text_formatter_field_stores_callable():
formatter = lambda props, value: f"{value:.2f}m" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
assert config.text_formatter is not None
assert callable(config.text_formatter)
def test_text_formatter_receives_props_and_value():
formatter = lambda props, value: f"{props.label}={value}" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
props = SimpleNamespace(label="L")
assert config.text_formatter(props, 3.14) == "L=3.14"
@@ -1,176 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Contract tests for the shared decorator cache module.
The cache token + persistent handler are the only thing protecting cached
``bpy.types.Object`` refs in dependent decorators from being dereferenced
after the underlying object is freed. These tests pin that contract:
- The 4-hook invalidation list (depsgraph/undo/redo/load) is symmetrically
managed by install/uninstall. A future edit that drops a hook from one
side without the other lands as a Blender segfault the regression must
surface as a test failure first.
- The handler increments the token and accepts Blender's variadic args."""
import bpy
import pytest
from bonsai.bim import decorator_cache
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _reset_cache_token():
"""Fresh token between tests so the bump-count assertions are stable."""
decorator_cache.reset_for_test()
yield
def test_install_and_uninstall_manage_all_invalidation_hooks():
"""install_decorator_cache_handlers() must register the bump handler in
every hook the dependent decorators rely on; uninstall must remove it
from every hook install touched. Catches the regression class where
a hook is dropped from one side and not the other."""
expected_hooks = (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
# Defensive cleanup in case a previous addon-init run left the handler
# registered — the test must observe a clean slate before install().
for hook in expected_hooks:
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
try:
decorator_cache.install_decorator_cache_handlers()
for hook in expected_hooks:
assert decorator_cache._bump_decorator_cache_token in hook, (
"install_decorator_cache_handlers() must register the bump "
"handler in every hook a dependent cache relies on"
)
decorator_cache.uninstall_decorator_cache_handlers()
for hook in expected_hooks:
assert decorator_cache._bump_decorator_cache_token not in hook, (
"uninstall_decorator_cache_handlers() must remove the bump " "handler from every hook install touched"
)
finally:
# Make sure the test never leaves the handler dangling.
for hook in expected_hooks:
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
def test_install_is_idempotent():
"""Calling install twice must not double-register the bump handler —
the addon-init path may run on script reload and we don't want to
invalidate the cache twice per event."""
hook = bpy.app.handlers.depsgraph_update_post
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
try:
decorator_cache.install_decorator_cache_handlers()
decorator_cache.install_decorator_cache_handlers()
appearances = sum(1 for h in hook if h is decorator_cache._bump_decorator_cache_token)
assert appearances == 1, "install must not double-register"
finally:
decorator_cache.uninstall_decorator_cache_handlers()
def test_bump_handler_increments_token():
"""undo / redo / load_post invoke the handler with at most one positional
argument (the scene or filepath). Every such call must bump the token
those events legitimately invalidate every cached Object reference."""
decorator_cache._bump_decorator_cache_token()
assert decorator_cache.get_decorator_cache_token() == 1
decorator_cache._bump_decorator_cache_token("scene")
assert decorator_cache.get_decorator_cache_token() == 2
def test_get_decorator_cache_token_reads_current_value():
"""``get_decorator_cache_token()`` is the public read interface — it must
reflect the current token, not a captured-at-import-time value."""
initial = decorator_cache.get_decorator_cache_token()
decorator_cache._bump_decorator_cache_token()
assert decorator_cache.get_decorator_cache_token() == initial + 1
def test_depsgraph_update_with_no_object_changes_does_not_bump():
"""depsgraph_update_post fires every animation frame, every driver
evaluation, and every UI-only state shift. None of those invalidate a
decorator's cached IFC-derived geometry — gating the bump is what makes
the ``TokenCache`` worth more than a per-frame recompute."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [] # empty updates list — animation tick with no real changes
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert (
decorator_cache.get_decorator_cache_token() == initial
), "depsgraph_update_post with no Object changes must not bump the token"
def test_depsgraph_update_with_object_geometry_change_bumps():
"""When the depsgraph reports an Object geometry or transform change,
cached references may now point at a renamed / freed ID block. The token
must advance so dependent caches re-fetch on the next read."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
update.is_updated_geometry = True
update.is_updated_transform = False
update.id = bpy.data.objects.new("dep_cache_probe", None)
try:
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [update]
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert decorator_cache.get_decorator_cache_token() == initial + 1
finally:
bpy.data.objects.remove(update.id, do_unlink=True)
def test_depsgraph_update_with_non_object_change_does_not_bump():
"""Material / NodeTree / Image updates fire depsgraph_update_post too
but never invalidate the decorator's Object-keyed caches. Filter them
out so a node-graph edit doesn't trigger a global cache rebuild."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
update.is_updated_geometry = True
update.is_updated_transform = True
update.id = bpy.data.materials.new("dep_cache_probe_mat")
try:
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [update]
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert (
decorator_cache.get_decorator_cache_token() == initial
), "Non-Object ID updates must not bump the decorator cache token"
finally:
bpy.data.materials.remove(update.id, do_unlink=True)
@@ -1,96 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Behaviour contracts for the wall-fillet operator chain.
Each fillet operator's geometry path requires real Blender + IFC fixtures
(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end
fillet round-trips belong in the bim feature suite (model.feature) where
that scaffolding already exists. This file pins the surface-level invariants
that don't depend on the geometry path:
* the lifecycle operators are registered under their conventional bl_idnames,
* the enable poll rejects ineligible selections.
State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were
removed because the dispatch is flaky in full-suite ordering the operator
early-returns when ``context.screen`` is unattached and prior tests can leave
the screen in that state. The behaviour is covered by the user-visible live
test loop instead."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _fillet_op_names():
"""Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` —
avoids hard-coding the five lifecycle bl_idnames so adding / renaming
one updates discovery automatically. Each name maps to a callable
operator."""
return sorted(name for name in dir(bpy.ops.bim) if "wall_fillet" in name)
class TestFilletOperatorsRegistered:
"""Catches accidental deregistration of any fillet lifecycle operator —
drops in the classes tuple of bim/module/model/__init__.py would otherwise
leave the gizmo group's target_set_operator binding pointing at a missing
op and crash the first time a user clicked the icon."""
def test_at_least_the_expected_lifecycle_set_is_registered(self):
names = _fillet_op_names()
# The lifecycle has enable + finish + cancel as a minimum; a healthy
# build also includes the from-corner re-edit entry and the create
# operator the finish dispatches to. The test asserts at least four —
# below that the feature can't function — without enumerating each
# by name, so the test stays meaningful if one is renamed or merged.
assert len(names) >= 4, (
f"Only {len(names)} fillet operators found on bpy.ops.bim: {names}. "
"The fillet lifecycle needs enable + finish + cancel + create at "
"minimum; check bim/module/model/__init__.py classes tuple."
)
def test_every_discovered_fillet_op_is_callable(self):
for name in _fillet_op_names():
op = getattr(bpy.ops.bim, name)
assert callable(op), f"bpy.ops.bim.{name} is not callable — registration broke?"
class TestEnableRejectsIneligibleSelection:
"""The preview enable operator requires a specific 2-wall selection
(LAYER2 walls with straight axes). With no selection at all, poll
must return False so the operator is greyed-out in menus instead of
crashing on dispatch."""
def test_enable_poll_returns_false_with_no_selection(self):
# Deselect everything in the default scene; no IfcWall is present
# in a fresh bpy_extras context anyway, so poll() must short-circuit.
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.update()
assert bpy.ops.bim.enable_wall_fillet_preview.poll() is False
@@ -1,521 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for tool.Geometry.get_dissolved_edges and the opening-decoration cache
layers. The dissolve helper's contract:
- Read-only on the input mesh.
- Returns (verts_local, edge_indices) indexed into the dissolved bmesh.
- Material seams survive (delimit=MATERIAL).
- Default angle threshold is 1°."""
from math import radians
import bmesh
import bpy
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim import decorator_cache
from bonsai.bim.module.model import opening as opening_module
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _reset_decoration_caches():
# Tests share module-global state (dissolve cache + token, world-draw-data
# cache, batch cache, per-object epochs). Reset every layer so a previous
# test can't poison hit/miss assertions.
decorator_cache.reset_for_test()
opening_module._dissolved_edges_cache.clear()
opening_module._dissolved_edges_cache_token = -1
opening_module._world_draw_data_cache.clear()
opening_module._batch_cache.clear()
opening_module._object_epochs.clear()
yield
decorator_cache.reset_for_test()
opening_module._dissolved_edges_cache.clear()
opening_module._world_draw_data_cache.clear()
opening_module._batch_cache.clear()
opening_module._object_epochs.clear()
def _make_mesh(name: str, verts: list[tuple[float, float, float]], faces: list[tuple[int, ...]]) -> bpy.types.Mesh:
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(verts, [], faces)
mesh.update()
return mesh
def _edge_count(mesh: bpy.types.Mesh) -> int:
bm = bmesh.new()
bm.from_mesh(mesh)
n = len(bm.edges)
bm.free()
return n
def test_collapses_coplanar_diagonal_on_triangulated_quad():
# Triangulated unit quad in the XY plane: 4 verts, 2 tris share a diagonal.
# Raw bmesh has 5 edges (4 quad sides + 1 diagonal). Dissolve must drop the
# diagonal because both triangles are perfectly coplanar.
mesh = _make_mesh(
"quad_tri",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
assert _edge_count(mesh) == 5
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
assert len(verts) == 4
assert len(edges) == 4
# Every returned edge index must point into the returned verts list.
for a, b in edges:
assert 0 <= a < len(verts)
assert 0 <= b < len(verts)
assert a != b
def test_preserves_real_edges_on_cube():
# Default cube has 8 verts / 12 edges / 6 quad faces. There are no coplanar
# internal splits to dissolve, so the helper must return the cube intact.
mesh = bpy.data.meshes.new("cube")
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bm.to_mesh(mesh)
bm.free()
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
assert len(verts) == 8
assert len(edges) == 12
def test_preserves_material_seam_on_coplanar_split():
# Two coplanar triangles sharing an edge but each with a different
# material_index. delimit=MATERIAL must keep the shared edge alive.
mesh = _make_mesh(
"split_mat",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
mat_a = bpy.data.materials.new("mat_a")
mat_b = bpy.data.materials.new("mat_b")
mesh.materials.append(mat_a)
mesh.materials.append(mat_b)
mesh.polygons[0].material_index = 0
mesh.polygons[1].material_index = 1
mesh.update()
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
# The 4 perimeter edges plus the shared diagonal: 5 total survive.
assert len(verts) == 4
assert len(edges) == 5
bpy.data.materials.remove(mat_a)
bpy.data.materials.remove(mat_b)
def test_does_not_mutate_input_mesh():
# The helper must be read-only: viewport draw handlers call it every frame
# and any obj.data mutation would race the depsgraph and trigger redraws.
mesh = _make_mesh(
"ro_quad",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
edges_before = _edge_count(mesh)
verts_before = len(mesh.vertices)
tool.Geometry.get_dissolved_edges(mesh)
assert _edge_count(mesh) == edges_before
assert len(mesh.vertices) == verts_before
def test_accepts_explicit_angle_limit():
# Smoke: the angle_limit kwarg must be honored end-to-end (not silently
# ignored). With a near-zero threshold, even sub-degree coplanar splits
# survive; with a generous threshold, they collapse.
mesh = _make_mesh(
"quad_tri",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
_, edges_zero = tool.Geometry.get_dissolved_edges(mesh, angle_limit=0.0)
_, edges_default = tool.Geometry.get_dissolved_edges(mesh)
assert len(edges_zero) > len(edges_default), "angle_limit=0 must preserve more edges than the default 1° dissolve"
def test_cache_serves_identical_object_on_repeat_call():
# Without caching, the helper rebuilds verts/edges every viewport redraw.
# Identity (`is`) — not equality — proves the second call hit the cache
# rather than recomputing identical content.
mesh = _make_mesh(
"cached",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
first = opening_module._get_cached_dissolved_edges(mesh)
second = opening_module._get_cached_dissolved_edges(mesh)
assert first is second
def test_cache_invalidates_on_decorator_token_bump():
# depsgraph_update_post / undo / redo / load all bump the shared decorator
# token; this cache must clear when the token changes so a downstream
# depsgraph edit (mesh content changed) is reflected on the next call.
mesh = _make_mesh(
"bumped",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
first = opening_module._get_cached_dissolved_edges(mesh)
decorator_cache._DECORATOR_CACHE_TOKEN += 1
second = opening_module._get_cached_dissolved_edges(mesh)
assert first is not second, "token bump must invalidate the cache entry"
assert len(first[0]) == len(second[0])
assert len(first[1]) == len(second[1])
def test_cache_partitions_entries_by_mesh_identity():
# Two distinct meshes share the same epoch; both must coexist in the cache
# so multi-opening frames don't thrash.
mesh_a = _make_mesh(
"a",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
mesh_b = _make_mesh(
"b",
verts=[(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
a_first = opening_module._get_cached_dissolved_edges(mesh_a)
b_first = opening_module._get_cached_dissolved_edges(mesh_b)
a_second = opening_module._get_cached_dissolved_edges(mesh_a)
assert a_first is a_second, "mesh_a entry must survive an interleaved mesh_b call"
assert a_first is not b_first
def test_cache_partitions_entries_by_angle_limit():
# Same mesh, different angle_limit → different cached results. Hardens
# against a future caller introducing a per-opening threshold override.
mesh = _make_mesh(
"partitioned",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
tight = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
loose = opening_module._get_cached_dissolved_edges(mesh, angle_limit=radians(1.0))
tight_again = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
assert tight is tight_again
assert tight is not loose
# --- world-data cache (_get_cached_world_draw_data) ---------------------------
def _make_object(name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
return obj
def _make_triangulated_quad_obj(name: str) -> bpy.types.Object:
mesh = _make_mesh(
name,
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
return _make_object(name, mesh)
def test_world_data_cache_returns_four_tuple_with_expected_shapes():
obj = _make_triangulated_quad_obj("shape")
line_verts, verts, edges_indices, tris = opening_module._get_cached_world_draw_data(obj)
assert len(verts) == 4 # full mesh vert count
assert len(line_verts) == 4 # dissolved (diagonal collapsed → 4 surviving verts)
assert len(edges_indices) == 4 # quad outline, no diagonal
assert len(tris) == 2 # two triangles
assert all(len(t) == 3 for t in tris)
def test_world_data_cache_hit_returns_identical_tuple_on_repeat_call():
obj = _make_triangulated_quad_obj("hit")
first = opening_module._get_cached_world_draw_data(obj)
second = opening_module._get_cached_world_draw_data(obj)
assert first is second
def test_world_data_cache_invalidates_on_object_epoch_bump():
# depsgraph_update_post bumps per-object epochs (one per Object whose
# transform or geometry changed). After bumping this object's epoch the
# next lookup must miss and recompute.
obj = _make_triangulated_quad_obj("bumped")
first = opening_module._get_cached_world_draw_data(obj)
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
second = opening_module._get_cached_world_draw_data(obj)
assert first is not second
def test_world_data_cache_partitions_entries_by_object_identity():
a = _make_triangulated_quad_obj("a")
b = _make_triangulated_quad_obj("b")
a_first = opening_module._get_cached_world_draw_data(a)
b_first = opening_module._get_cached_world_draw_data(b)
a_second = opening_module._get_cached_world_draw_data(a)
assert a_first is a_second
assert a_first is not b_first
def test_world_data_cache_reflects_new_matrix_after_epoch_bump():
# The cache stores world-space verts. A transform without an epoch bump
# would serve stale coordinates — but transform updates bump the object's
# epoch via the depsgraph handler, so after bump + recompute the new
# matrix must be reflected.
obj = _make_triangulated_quad_obj("moved")
before = opening_module._get_cached_world_draw_data(obj)
obj.matrix_world = obj.matrix_world @ Matrix.Translation((5.0, 0.0, 0.0))
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
after = opening_module._get_cached_world_draw_data(obj)
# Each vert in `after` is 5 units shifted on X relative to `before`.
for a_co, b_co in zip(after[1], before[1]):
assert a_co[0] - b_co[0] == pytest.approx(5.0)
assert a_co[1] == pytest.approx(b_co[1])
assert a_co[2] == pytest.approx(b_co[2])
def test_world_data_cache_ios_edges_path_returns_curated_edges():
# When the mesh has an ios_edges attribute, line_verts must equal the full
# verts (no dissolve), and edges_indices must include only entries where
# the attribute is True.
obj = _make_triangulated_quad_obj("curated")
attr = obj.data.attributes.new(name="ios_edges", type="BOOLEAN", domain="EDGE")
# 5 edges total (quad + diagonal). Mark only the 4 quad sides as real.
bm = bmesh.new()
bm.from_mesh(obj.data)
real_edges_count = 0
for i, edge in enumerate(bm.edges):
is_diagonal = (
abs(edge.verts[0].co[0] - edge.verts[1].co[0]) > 0 and abs(edge.verts[0].co[1] - edge.verts[1].co[1]) > 0
)
attr.data[i].value = not is_diagonal
if not is_diagonal:
real_edges_count += 1
bm.free()
obj.data.update()
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
assert line_verts is verts, "ios_edges path must reuse the full-verts list as line_verts"
assert len(edges_indices) == real_edges_count
def test_world_data_cache_dissolve_path_drops_diagonal():
# Without ios_edges, the cache falls through to dissolve. The 5th edge
# (diagonal) must be gone from edges_indices.
obj = _make_triangulated_quad_obj("dissolved")
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
assert len(edges_indices) == 4
assert len(line_verts) == 4
assert len(verts) == 4
# --- batch cache (_get_cached_batch_or_none / _store_batch_in_cache) ---------
def test_batch_cache_returns_none_on_cold_lookup():
assert opening_module._get_cached_batch_or_none((123, "lines")) is None
def test_batch_cache_returns_stored_batch_on_hit():
# Sentinel stands in for a GPUBatch — the cache treats it opaquely, so
# this test pins lookup/store correctness without needing a real shader.
sentinel = object()
opening_module._store_batch_in_cache((42, "lines"), sentinel)
assert opening_module._get_cached_batch_or_none((42, "lines")) is sentinel
def test_batch_cache_invalidates_on_object_epoch_bump():
sentinel = object()
opening_module._store_batch_in_cache((42, "lines"), sentinel)
opening_module._object_epochs[42] = opening_module._object_epochs.get(42, 0) + 1
assert opening_module._get_cached_batch_or_none((42, "lines")) is None
def test_batch_cache_partitions_entries_by_kind():
# Same object, different batch kinds (LINES vs TRIS vs arrow) coexist —
# required so the same opening's three batches don't evict each other.
lines_batch = object()
tris_batch = object()
opening_module._store_batch_in_cache((42, "lines"), lines_batch)
opening_module._store_batch_in_cache((42, "tris"), tris_batch)
assert opening_module._get_cached_batch_or_none((42, "lines")) is lines_batch
assert opening_module._get_cached_batch_or_none((42, "tris")) is tris_batch
def test_batch_cache_partitions_entries_by_object_uid():
a_batch = object()
b_batch = object()
opening_module._store_batch_in_cache((1, "lines"), a_batch)
opening_module._store_batch_in_cache((2, "lines"), b_batch)
assert opening_module._get_cached_batch_or_none((1, "lines")) is a_batch
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_batch
# --- per-object epoch invalidation (granularity contract) --------------------
def test_world_data_cache_per_object_epoch_invalidates_only_target():
# Core contract for the granular-invalidation feature: bumping one object's
# epoch must not evict another object's cached payload. This is what makes
# dragging a single object in a 50-opening scene affordable.
a = _make_triangulated_quad_obj("granular_a")
b = _make_triangulated_quad_obj("granular_b")
a_first = opening_module._get_cached_world_draw_data(a)
b_first = opening_module._get_cached_world_draw_data(b)
opening_module._object_epochs[a.session_uid] = opening_module._object_epochs.get(a.session_uid, 0) + 1
a_second = opening_module._get_cached_world_draw_data(a)
b_second = opening_module._get_cached_world_draw_data(b)
assert a_first is not a_second, "a's epoch bump must invalidate a's entry"
assert b_first is b_second, "a's epoch bump must NOT touch b's entry"
def test_batch_cache_per_object_epoch_invalidates_only_target():
a_lines = object()
b_lines = object()
opening_module._store_batch_in_cache((1, "lines"), a_lines)
opening_module._store_batch_in_cache((2, "lines"), b_lines)
opening_module._object_epochs[1] = opening_module._object_epochs.get(1, 0) + 1
assert opening_module._get_cached_batch_or_none((1, "lines")) is None
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_lines
def test_global_clear_handler_wipes_everything():
# undo/redo/load can't be modeled as per-object deltas — the global handler
# must wipe every layer (epochs + both caches) so we can never serve state
# that pre-dates the undo/load.
a = _make_triangulated_quad_obj("wipe_a")
opening_module._get_cached_world_draw_data(a)
opening_module._store_batch_in_cache((a.session_uid, "lines"), object())
assert a.session_uid in opening_module._world_draw_data_cache
assert (a.session_uid, "lines") in opening_module._batch_cache
opening_module._clear_decoration_caches_globally()
assert opening_module._world_draw_data_cache == {}
assert opening_module._batch_cache == {}
assert opening_module._object_epochs == {}
class _FakeDepsgraphUpdate:
def __init__(self, id_, transform: bool = False, geometry: bool = False):
self.id = id_
self.is_updated_transform = transform
self.is_updated_geometry = geometry
class _FakeDepsgraph:
def __init__(self, updates):
self.updates = updates
def test_depsgraph_handler_bumps_epoch_for_updated_object():
# Synthesised depsgraph delta: one Object with a transform update. The
# handler must increment that object's epoch.
obj = _make_triangulated_quad_obj("bumped_via_handler")
before = opening_module._object_epochs.get(obj.session_uid, 0)
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert opening_module._object_epochs[obj.session_uid] == before + 1
def test_depsgraph_handler_ignores_non_object_updates():
# Updates whose .id isn't a bpy.types.Object (Mesh, Material, NodeTree…)
# must not affect any object's epoch.
obj = _make_triangulated_quad_obj("untouched")
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj.data, geometry=True)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert obj.session_uid not in opening_module._object_epochs
def test_depsgraph_handler_ignores_updates_without_transform_or_geometry():
# An Object update flagged only for shading must not bump the epoch —
# shading changes don't move the wire overlay.
obj = _make_triangulated_quad_obj("shading_only")
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert obj.session_uid not in opening_module._object_epochs
def test_depsgraph_handler_resolves_cow_original():
# For non-evaluated Blender objects, obj.original returns obj itself, so
# the .original-resolution path keys the SAME uid the draw handler reads.
# Pinning this prevents a future refactor that drops the .original lookup
# from silently regressing the COW-boundary case (the decorator failing to
# follow a moved object).
obj = _make_triangulated_quad_obj("cow")
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert obj.original.session_uid in opening_module._object_epochs
def test_depsgraph_handler_tolerates_missing_depsgraph():
# Some Blender event paths may call the handler without a depsgraph; the
# handler must short-circuit instead of raising AttributeError.
opening_module._bump_object_epochs_for_decoration()
opening_module._bump_object_epochs_for_decoration(None)
opening_module._bump_object_epochs_for_decoration(None, None)
assert opening_module._object_epochs == {}
@@ -1,178 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for the parametric-edit preview registry contract.
Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard-
coding preview keys or cancel-operator names, so adding a new preview to the
registry automatically exercises the same invariants without test changes."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _registry():
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
return PREVIEW_CANCEL_OPS
def _preview_umbrella():
return getattr(bpy.context.scene, "BIMPreviewProperties", None)
def _registered_previews():
"""``[(attr, op_name, props)]`` for every registry entry that has a real
child PropertyGroup on the umbrella in the current addon build."""
umbrella = _preview_umbrella()
if umbrella is None:
return []
out = []
for attr, op_name in _registry():
props = getattr(umbrella, attr, None)
if props is not None:
out.append((attr, op_name, props))
return out
class TestRegistryContract:
"""Pins the invariant that every entry in PREVIEW_CANCEL_OPS resolves to
a real cancel operator the addon registers. A new preview added to the
registry without its matching cancel operator would otherwise crash
``try_cancel_active_preview`` on the first Esc."""
def test_every_registered_cancel_op_is_callable(self):
for attr, op_name in _registry():
op = getattr(bpy.ops.bim, op_name, None)
assert op is not None and callable(op), (
f"Preview '{attr}' in PREVIEW_CANCEL_OPS points to bim.{op_name} "
f"but no such operator is registered."
)
class TestGetPreviewPropsTolerance:
"""The bug-class fixed in commit ee63137c6: ``get_preview_props`` is called
from gizmo polls during addon init and from test mocks built on
``SimpleNamespace`` neither has a fully-formed Blender context. The
helper must return None rather than raise."""
def test_returns_none_when_context_has_no_scene(self):
from bonsai.bim.module.model.preview_base import get_preview_props
# Pass an arbitrary attr name — the contract is the same for every
# preview key, so picking one literally would be a maintenance trap.
for attr, _ in _registry():
assert get_preview_props(types.SimpleNamespace(), attr) is None
break
def test_returns_none_when_scene_lacks_umbrella(self):
from bonsai.bim.module.model.preview_base import get_preview_props
ctx = types.SimpleNamespace(scene=types.SimpleNamespace())
for attr, _ in _registry():
assert get_preview_props(ctx, attr) is None
break
class TestActivationCycle:
"""End-to-end contract on the real addon: each registered preview can be
activated and then cancelled to inactive. Runs for every preview that
has a wired PropertyGroup, so a new preview added to the registry +
umbrella is covered without test edits."""
def test_any_preview_active_reflects_each_preview_state(self):
from bonsai.bim.module.model.preview_base import any_preview_active
registered = _registered_previews()
if not registered:
pytest.skip("No previews wired in this build — registry-only entries")
# All inactive baseline.
for _, _, props in registered:
props.is_active = False
assert any_preview_active(bpy.context) is False
# Flip each one independently — the helper must report True.
for _, _, props in registered:
props.is_active = True
assert any_preview_active(bpy.context) is True
props.is_active = False
def test_discard_pending_previews_clears_every_active_flag(self):
from bonsai.bim.module.model.preview_base import discard_pending_previews
registered = _registered_previews()
if not registered:
pytest.skip("No previews wired in this build — registry-only entries")
for _, _, props in registered:
props.is_active = True
discard_pending_previews(bpy.context.scene)
for attr, _, props in registered:
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
class TestSaveOnDiscardWired:
"""Pins that the SaveProject operator clears preview state before writing
the IFC file a stuck is_active flag persisted through the save would
silently hide sister gizmos on the next file load.
Structural check: the SaveProject operator class must reference the
discard helper somewhere in its execute path. Behavioural integration
(actually saving a .blend with an active preview and reloading) belongs
in the bim feature suite; this is the small guard against accidental
removal of the call site."""
def test_save_project_dispatches_discard_pending_previews(self):
import inspect
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.project import operator as project_operator
# Find the project save operator dynamically — looking for any
# Operator class whose bl_idname is "bim.save_project". Avoids
# hard-coding the class identifier.
save_op = None
for name in dir(project_operator):
obj = getattr(project_operator, name)
if isinstance(obj, type) and getattr(obj, "bl_idname", None) == "bim.save_project":
save_op = obj
break
assert save_op is not None, "Expected an operator with bl_idname='bim.save_project' in project/operator.py"
# Walk the class's methods for the discard call. Avoids pinning a
# specific method name (_execute vs execute vs an inner helper) so
# the test survives operator refactors.
source = inspect.getsource(save_op)
assert preview_base.discard_pending_previews.__name__ in source, (
f"{save_op.__name__} does not reference discard_pending_previews. "
"Saving with a preview open would persist its is_active flag to the "
".blend file and silently hide sister gizmos on reopen."
)
@@ -1,135 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression guard for the stair icon billboard fix.
Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos``
composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the
stair's world rotation on top of the billboard rotation. The result was
icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on
to the camera for any stair rotated in plan effectively unclickable.
The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``,
which computes ``Translation(world_pos) @ billboard_rot @ Scale`` the
object's rotation is folded into the translation only, never the rotation."""
import math
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
for row_a, row_b in zip(a, b):
for va, vb in zip(row_a, row_b):
if abs(va - vb) > tol:
return False
return True
@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217])
def test_billboarded_at_rotation_is_pure_billboard(angle_deg):
"""Object rotation must not leak into the gizmo's rotation part."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
world_pos = mw @ Vector((1, 0, 2))
result = billboarded_at(world_pos, billboard_rot, scale=0.5)
# The rotation part of result, after stripping the 0.5 uniform scale,
# must equal billboard_rot — no contribution from mw's rotation.
rotation_part = result.to_3x3() * 2.0
assert _rotation_close(rotation_part.to_4x4(), billboard_rot)
def test_billboarded_at_translation_is_world_pos():
"""Translation lands exactly at the world-space target."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
world_pos = Vector((1.23, 4.56, 7.89))
result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5)
assert (result.translation - world_pos).length < 1e-6
def test_set_icon_gizmo_position_does_not_apply_object_rotation():
"""End-to-end: the helper used by every stair icon (and shared with all
parametric gizmo groups) must produce a matrix whose rotation part is
billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug
that left stair icons edge-on to the camera."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import (
BaseParametricGizmoGroup,
billboarded_at,
)
# Same inputs as the real call site (stair.py:747-765), but we drive the
# helper directly so we don't need a registered GizmoGroup. We bind a
# stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's
# observable output is the matrix_basis it assigns.
captured = {}
class _GizmoStub:
matrix_basis = Matrix.Identity(4)
stub = _GizmoStub()
def _fake_get(name):
captured["name"] = name
return stub
# Bind the helper to a throwaway instance so `self.get_gizmo_if_visible`
# resolves to our stub without registering a real GizmoGroup with Blender.
fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get)
mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
local_pos = Vector((1, 0, 2))
BaseParametricGizmoGroup.set_icon_gizmo_position(
fake_self,
"validate_gizmo",
mw=mw,
x=local_pos.x,
y=local_pos.y,
z=local_pos.z,
billboard_rot=billboard_rot,
scale=0.5,
)
expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5)
assert captured["name"] == "validate_gizmo"
for row_a, row_b in zip(stub.matrix_basis, expected):
for va, vb in zip(row_a, row_b):
assert abs(va - vb) < 1e-6

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