mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99cfa2bd56 | |||
| 8402dfd304 | |||
| f158ae7377 | |||
| 5dde402f8b | |||
| 1daee04d9c | |||
| 0d7c378db5 | |||
| 1c128a2d6a | |||
| 36372627db | |||
| a9b6f02f02 | |||
| 9e7d97e298 | |||
| 187b8e7167 | |||
| 08e33fe572 | |||
| e142b9d7b4 | |||
| 431cf435ef | |||
| a433f56337 | |||
| a3f92eb427 | |||
| 453e6dc1cc | |||
| 728026d3f0 | |||
| 3f270df11e | |||
| 792a0c7da1 | |||
| 5856d29fbe | |||
| cd482a7874 | |||
| f6e95c8e8e | |||
| ee63137c6c | |||
| c83b4eb69f | |||
| 5eef433abf | |||
| 7e5e7b8d6a | |||
| 788d4fe8e8 | |||
| a1c2aecf1b | |||
| d501970352 | |||
| fd96e6a4d2 | |||
| 3dd3a0d70c | |||
| 2e5995176a | |||
| 2114c1d5d0 | |||
| 3f680f5c21 | |||
| 97e8deb069 | |||
| 6ba5f5af3d | |||
| 335ee1a1bb | |||
| 49348908e6 | |||
| c250b2c1a7 | |||
| 6874d52100 | |||
| 6c21e2b6f4 | |||
| 70845e4dd4 | |||
| d7b5ac1453 | |||
| 1961cd905e | |||
| f1cf757ba2 | |||
| b039e12623 | |||
| 1325705d8e | |||
| cb2f20b2b6 | |||
| f41f5dfdd8 | |||
| 1855e4c019 | |||
| 2feade01cb | |||
| ff4c642db1 | |||
| e7e489e390 | |||
| 5e23030a0f | |||
| c9f12dd441 | |||
| 4b9ad66c95 | |||
| d43a1353e0 | |||
| b1fa2407a9 | |||
| 786d3c8a89 | |||
| 89b7eff03e | |||
| 1c8fad3c13 | |||
| 6ec8372378 | |||
| 5dc7513de0 | |||
| f37c77e80c | |||
| db9d903650 | |||
| 3483683cb4 | |||
| a0c6f6f9a6 | |||
| 49ddda6281 | |||
| 96b6985960 | |||
| b19b2ac7cd | |||
| fdf4b82371 | |||
| 2f40441f1c | |||
| 230cbe1fd8 | |||
| 4d4c5b4d51 | |||
| 3d81660dad | |||
| b4abd999b6 | |||
| 1e6db764d4 | |||
| 936526b41b | |||
| 45ea5eb07a | |||
| 42ed398169 | |||
| f70044d373 | |||
| 719309571e | |||
| d3f0ad03fb | |||
| 7e96692764 | |||
| 3e0978062f | |||
| b3f482e0fa | |||
| 4943c77c5e | |||
| 6caf94f1d3 | |||
| 1e36cc318e | |||
| 46381ec08b | |||
| 47af955dd1 | |||
| f582d0230c | |||
| 26eef20eb5 | |||
| 2143262883 | |||
| 233cc344fa | |||
| a64e737d9c | |||
| 1b2507e143 | |||
| 508b99cb73 | |||
| 4e406ab1ce | |||
| 227d85d81f | |||
| a24cdf4958 | |||
| e78ef865b8 | |||
| 9345b9ce3f | |||
| 0b5dded3b3 | |||
| 97218b1fdb | |||
| 1b637c6499 | |||
| 47312e1fbb | |||
| 7aa2bb366e | |||
| c197a45247 | |||
| ab73550059 | |||
| 53c2ddbb47 | |||
| 7c6f6a4176 | |||
| 261037fb82 | |||
| eacbb55810 | |||
| 3d05a5e9d1 | |||
| cb3253b57c | |||
| a23cb3744f | |||
| 57ef96a909 | |||
| 674d98dbb3 | |||
| c58711a8f7 | |||
| e1a7214a29 | |||
| 852d620dc6 | |||
| 856631092b | |||
| 7a61cf20a4 | |||
| c999a92aa7 | |||
| 434b179ed9 | |||
| 8b5b4006aa | |||
| 98c24b95f3 | |||
| 33809c7266 | |||
| 247a445458 | |||
| 421fab45f3 | |||
| 57982a0d99 | |||
| c39fe6e8a3 | |||
| e4f5c630db |
@@ -53,7 +53,7 @@ jobs:
|
||||
python ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
with:
|
||||
key: mac-${{ matrix.arch }}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python ../IfcOpenShell/nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
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.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
uv run ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
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.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
uv run ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
with:
|
||||
key: win-${{ matrix.arch }}
|
||||
# Windows ccache needs ~1GB
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
-
|
||||
name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
|
||||
-
|
||||
name: Build ifcopenshell
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uv tool install ruff
|
||||
uv tool install black
|
||||
uv tool install poethepoet
|
||||
uv tool install ty
|
||||
uv tool install ty==0.0.34
|
||||
|
||||
# black doesn't catch all syntax errors, so we check them explicitly.
|
||||
- name: Check syntax errors
|
||||
|
||||
@@ -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
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing
|
||||
pip install src/bcf --no-deps
|
||||
pip install pytest-xdist==3.8.0
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
libhdf5-dev libcgal-dev libeigen3-dev
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- run: uv run .github/scripts/publish-bonsai-releases.py
|
||||
env:
|
||||
|
||||
@@ -258,10 +258,14 @@ if(WITH_ROCKSDB)
|
||||
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
|
||||
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
|
||||
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
|
||||
# So rocksdb supported only as a static library.
|
||||
# See https://github.com/facebook/rocksdb/issues/981.
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
if(TARGET RocksDB::rocksdb)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
elseif(TARGET RocksDB::rocksdb-shared)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb-shared)
|
||||
else()
|
||||
message(FATAL_ERROR "RocksDB found but neither RocksDB::rocksdb nor RocksDB::rocksdb-shared target exists")
|
||||
endif()
|
||||
|
||||
if(WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
|
||||
+1
-2
@@ -126,9 +126,8 @@ ssl._create_default_https_context = ssl._create_unverified_context
|
||||
import time
|
||||
from collections.abc import Generator, Sequence
|
||||
from pathlib import Path
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
from typing import Literal, Union
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/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.
|
||||
|
||||
@@ -11,14 +16,15 @@ source .venv/bin/activate
|
||||
|
||||
# Install pyodide cross build environment.
|
||||
# Instructions: https://pyodide.org/en/stable/development/building-packages.html
|
||||
uv pip install pyodide-build
|
||||
uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}"
|
||||
# `uv run` is required, so xbuildenv would skip using `pip`.
|
||||
uv run pyodide xbuildenv install
|
||||
uv run pyodide xbuildenv install "${PYODIDE_VERSION}"
|
||||
uv run pyodide xbuildenv install-emscripten
|
||||
|
||||
EMSDK_ROOT=$(pyodide config get emscripten_dir)
|
||||
source ${EMSDK_ROOT}/emsdk_env.sh
|
||||
EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk"
|
||||
source "${EMSDK_ROOT}/emsdk_env.sh"
|
||||
which emcc
|
||||
emcc --version
|
||||
|
||||
mkdir -p packages/ifcopenshell
|
||||
VERSION=`cat IfcOpenShell/VERSION`
|
||||
|
||||
+2
-2
@@ -3,9 +3,9 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.10",
|
||||
"ruff==0.15.12",
|
||||
"poethepoet",
|
||||
"ty==0.0.29",
|
||||
"ty==0.0.32",
|
||||
"gersemi==0.26.1",
|
||||
]
|
||||
|
||||
|
||||
+9
-1
@@ -192,7 +192,11 @@ endif
|
||||
# Provides networkx graph analysis for project dependency calculations
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
|
||||
# Required by IFCDiff
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
|
||||
# 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
|
||||
# Required by IFCCSV and ifcopenshell.util.selector
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
|
||||
# Required by IFC4D
|
||||
@@ -356,6 +360,10 @@ else
|
||||
pytest test/tool/test_$(MODULE).py --maxfail=1
|
||||
endif
|
||||
|
||||
.PHONY: test-modal
|
||||
test-modal:
|
||||
blender --enable-event-simulate --python test/modal/test_modal.py --window-maximized
|
||||
|
||||
# Reregistering test is not added to the standard test suite because during unregister
|
||||
# Blender removes all Bonsai dependencies breaking dev-environment symlinks.
|
||||
.PHONY: test-reregister
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import importlib
|
||||
import os
|
||||
@@ -25,7 +27,19 @@ import bpy
|
||||
import bpy.utils.previews
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from . import handler, operator, prop, ui
|
||||
from . import handler, operator, parametric_lifecycle, prop, ui
|
||||
|
||||
|
||||
def _parametric_gizmo_preference_classes() -> list[type]:
|
||||
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
|
||||
``classes`` list below. ``import bonsai.tool`` is kept local to surface
|
||||
the load-order constraint: it relies on ``from . import handler, …``
|
||||
above having primed the
|
||||
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
|
||||
import bonsai.tool as tool
|
||||
|
||||
return tool.Parametric.iter_gizmo_preference_classes(ui)
|
||||
|
||||
|
||||
try:
|
||||
from bonsai.translations import translations_dict
|
||||
@@ -157,9 +171,10 @@ classes = [
|
||||
ui.BIM_UL_tab_visibilities,
|
||||
ui.BIM_UL_panel_visibilities,
|
||||
ui.DocPreferences,
|
||||
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesStair, # Register before GizmoPreferences
|
||||
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
|
||||
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
|
||||
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
|
||||
*_parametric_gizmo_preference_classes(),
|
||||
ui.GizmoPreferences,
|
||||
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
|
||||
# Tabs panel
|
||||
@@ -268,6 +283,8 @@ 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)
|
||||
@@ -325,6 +342,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Shared structural-change cache token for POST_VIEW decorators.
|
||||
|
||||
Decorators include the token in their cache key and rebuild on bump."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
import bpy
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DECORATOR_CACHE_TOKEN = 0
|
||||
|
||||
|
||||
def get_decorator_cache_token() -> int:
|
||||
return _DECORATOR_CACHE_TOKEN
|
||||
|
||||
|
||||
def reset_for_test() -> None:
|
||||
"""Test-only: reset the cache token to 0 so bump-count assertions are stable."""
|
||||
global _DECORATOR_CACHE_TOKEN
|
||||
_DECORATOR_CACHE_TOKEN = 0
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _bump_decorator_cache_token(*args: Any) -> None:
|
||||
"""depsgraph_update_post fires every animation frame and every driver
|
||||
evaluation, even when no IFC-relevant ID block changed. Unconditional
|
||||
bumping defeats the cache: an animated scene rebuilds every decorator
|
||||
every viewport tick. Gate the depsgraph path on Object geometry or
|
||||
transform updates; undo / redo / load have no depsgraph and always
|
||||
invalidate.
|
||||
|
||||
Coverage assumption: ``TokenCache`` consumers key on Object identity
|
||||
(depsgraph updates whose ``id`` is a ``bpy.types.Object``). Mesh /
|
||||
Material / NodeTree updates that don't surface as an Object change
|
||||
do NOT invalidate the token — a decorator that caches material- or
|
||||
mesh-data-derived state must gate on a separate signal."""
|
||||
global _DECORATOR_CACHE_TOKEN
|
||||
if len(args) >= 2:
|
||||
depsgraph = args[1]
|
||||
if depsgraph is not None and hasattr(depsgraph, "updates"):
|
||||
if not any(
|
||||
(getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False))
|
||||
and hasattr(u, "id")
|
||||
and isinstance(u.id, bpy.types.Object)
|
||||
for u in depsgraph.updates
|
||||
):
|
||||
return
|
||||
_DECORATOR_CACHE_TOKEN += 1
|
||||
|
||||
|
||||
def _hooks() -> tuple[Any, ...]:
|
||||
return (
|
||||
bpy.app.handlers.depsgraph_update_post,
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
|
||||
def install_decorator_cache_handlers() -> None:
|
||||
"""Append the bump handler to each hook; idempotent."""
|
||||
for hook in _hooks():
|
||||
if _bump_decorator_cache_token not in hook:
|
||||
hook.append(_bump_decorator_cache_token)
|
||||
|
||||
|
||||
def uninstall_decorator_cache_handlers() -> None:
|
||||
for hook in _hooks():
|
||||
try:
|
||||
hook.remove(_bump_decorator_cache_token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
class TokenCache(Generic[T]):
|
||||
"""Memoise a single value keyed on ``(caller_key, get_decorator_cache_token())``.
|
||||
|
||||
The token component invalidates the cache on depsgraph / undo / redo / load,
|
||||
so cached ``bpy.types.Object`` references can't outlive the underlying ID
|
||||
blocks. Holds exactly one entry — last key wins."""
|
||||
|
||||
__slots__ = ("_key", "_value")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._key: tuple[Any, int] | None = None
|
||||
self._value: T | None = None
|
||||
|
||||
def get_or_compute(self, key: Any, compute: Callable[[], T]) -> T:
|
||||
token_key = (key, _DECORATOR_CACHE_TOKEN)
|
||||
if token_key == self._key:
|
||||
return self._value # type: ignore[return-value]
|
||||
value = compute()
|
||||
self._key = token_key
|
||||
self._value = value
|
||||
return value
|
||||
@@ -15,11 +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.
|
||||
|
||||
import os
|
||||
import weakref
|
||||
from collections.abc import Callable
|
||||
from math import cos
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
@@ -31,8 +32,13 @@ 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.ifc import IfcStore
|
||||
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.module.aggregate.decorator import AggregateDecorator
|
||||
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
@@ -40,7 +46,9 @@ 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__))
|
||||
@@ -133,14 +141,39 @@ 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
|
||||
aprops.relating_type_id = str(element_type.id())
|
||||
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
|
||||
return
|
||||
|
||||
if is_bim_tool:
|
||||
props.ifc_class = element_type.is_a()
|
||||
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
|
||||
|
||||
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())
|
||||
# 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_annotation_tool:
|
||||
return
|
||||
@@ -165,7 +198,9 @@ 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 = abs(extrusion.Depth * si_conversion * cos(x_angle))
|
||||
props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
|
||||
extrusion.Depth * si_conversion, x_angle
|
||||
)
|
||||
props.length = (axis[1] - axis[0]).length
|
||||
props.x_angle = x_angle
|
||||
|
||||
@@ -356,8 +391,10 @@ def subscribe_to_viewport_shading_changes():
|
||||
)
|
||||
|
||||
|
||||
@persistent
|
||||
def load_post(scene):
|
||||
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."""
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -368,6 +405,24 @@ def load_post(scene):
|
||||
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()
|
||||
@@ -391,11 +446,21 @@ def load_post(scene):
|
||||
tool.Blender.override_scene_panel(panel)
|
||||
tool.Blender.setup_tabs()
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
props.has_blend_warning = True
|
||||
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"}
|
||||
|
||||
# Bonsai overlays
|
||||
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()``."""
|
||||
georeference_props = tool.Georeference.get_georeference_props()
|
||||
aggregate_props = tool.Aggregate.get_aggregate_props()
|
||||
nest_props = tool.Nest.get_nest_props()
|
||||
@@ -405,23 +470,31 @@ def load_post(scene):
|
||||
NestDecorator.uninstall()
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
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)
|
||||
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 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"}
|
||||
|
||||
tool.Blender.sync_old_preferences()
|
||||
@persistent
|
||||
def load_post(scene):
|
||||
_apply_save_file_invariants(scene)
|
||||
_apply_user_preferences()
|
||||
_install_viewport_overlays()
|
||||
|
||||
@@ -64,6 +64,44 @@ 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``."""
|
||||
@@ -196,7 +234,7 @@ class IfcStore:
|
||||
shutil.copy2(IfcStore.cache_path, new_cache_path)
|
||||
except PermissionError:
|
||||
pass # Well we tried. No cache for you!
|
||||
IfcStore.get_cache()
|
||||
get_cache_or_detect_lock()
|
||||
|
||||
@staticmethod
|
||||
def load_file(path: str) -> None:
|
||||
@@ -514,6 +552,7 @@ 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,6 +139,7 @@ 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,
|
||||
@@ -155,5 +156,6 @@ class BIMAggregateProperties(PropertyGroup):
|
||||
previous_editing_aggregate: Union[bpy.types.Object, None]
|
||||
editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
not_editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
previously_selected_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
aggregate_decorator: bool
|
||||
previous_state: bool
|
||||
|
||||
@@ -48,12 +48,14 @@ 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 = attribute["name"]
|
||||
op.key = key_prefix + attribute["name"]
|
||||
|
||||
# TODO: reimplement, see #1222
|
||||
# if "IfcSite/" in context.active_object.name or "IfcBuilding/" in context.active_object.name:
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
|
||||
@@ -136,14 +138,32 @@ classes = (
|
||||
gizmos.GizmoArrow2D,
|
||||
gizmos.GizmoCone,
|
||||
gizmos.GizmoDimension,
|
||||
gizmos.GizmoLock,
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
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:
|
||||
if not feet and add_inches and unit_length != "INCHES":
|
||||
if value < 0:
|
||||
tx_dist += "-0' - "
|
||||
else:
|
||||
|
||||
@@ -44,8 +44,12 @@ class ViewportData:
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
cls.is_loaded = True
|
||||
# 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
|
||||
|
||||
@classmethod
|
||||
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
|
||||
@@ -60,6 +60,7 @@ 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:
|
||||
@@ -1183,7 +1184,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():
|
||||
if tool.Ifc.get() and tool.Model.has_selected_ifc_objects(include_active=False):
|
||||
IfcStore.execute_ifc_operator(operator, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1287,6 +1288,11 @@ 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")
|
||||
|
||||
@@ -2223,6 +2229,8 @@ 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
|
||||
@@ -2264,6 +2272,8 @@ 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"}
|
||||
@@ -2350,6 +2360,7 @@ 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)
|
||||
@@ -2358,6 +2369,7 @@ 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)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
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
|
||||
@@ -483,10 +484,32 @@ 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.prop(context.active_object, "location", text="Location")
|
||||
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 = self.layout.row()
|
||||
row.prop(context.active_object, "rotation_euler", text="Rotation")
|
||||
row.label(text="Rotation:")
|
||||
for i, axis in enumerate("XYZ"):
|
||||
self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis)
|
||||
|
||||
if props.blender_offset_type != "NONE":
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
@@ -630,13 +630,23 @@ 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:
|
||||
if "CardinalPoint" in attributes and attributes["CardinalPoint"] is not None:
|
||||
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)
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import (
|
||||
array,
|
||||
covering,
|
||||
@@ -57,6 +61,8 @@ classes = (
|
||||
array.Input3DCursorXArray,
|
||||
array.Input3DCursorYArray,
|
||||
array.Input3DCursorZArray,
|
||||
array.EnableEditingParametric,
|
||||
array.AddArrayFromFeatureEdit,
|
||||
product.AddDefaultType,
|
||||
product.AddEmptyType,
|
||||
product.AddOccurrence,
|
||||
@@ -70,19 +76,43 @@ 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,
|
||||
@@ -140,10 +170,14 @@ 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,
|
||||
@@ -264,15 +298,14 @@ 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)
|
||||
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)
|
||||
# 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.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)
|
||||
@@ -281,6 +314,12 @@ 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)
|
||||
@@ -288,13 +327,10 @@ 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
|
||||
del bpy.types.Object.BIMWindowProperties
|
||||
del bpy.types.Object.BIMDoorProperties
|
||||
del bpy.types.Object.BIMRailingProperties
|
||||
del bpy.types.Object.BIMRoofProperties
|
||||
tool.Parametric.unregister_object_properties()
|
||||
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)
|
||||
|
||||
@@ -379,3 +379,129 @@ 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"}
|
||||
|
||||
@@ -108,7 +108,7 @@ class ProfileDecorator:
|
||||
|
||||
obj = context.active_object
|
||||
|
||||
if obj.mode != "EDIT":
|
||||
if obj is None or obj.mode != "EDIT":
|
||||
if exit_edit_mode_callback:
|
||||
ProfileDecorator.uninstall()
|
||||
exit_edit_mode_callback()
|
||||
@@ -2029,3 +2029,151 @@ 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
|
||||
|
||||
@@ -38,6 +38,7 @@ 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
|
||||
@@ -566,103 +567,58 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
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):
|
||||
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 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"}
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class FinishEditingDoor(_DoorEditMixin, 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 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"}
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingDoor(_DoorEditMixin, 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 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"}
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._enable_targets(context)
|
||||
|
||||
|
||||
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -751,8 +707,8 @@ class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin)
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "is_door"
|
||||
props_getter = "get_door_props"
|
||||
element_checker = tool.Parametric.is_door
|
||||
props_getter = tool.Model.get_door_props
|
||||
type_literal = tool.Model.DoorType
|
||||
type_attr = "door_type"
|
||||
|
||||
@@ -879,7 +835,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = "get_door_props"
|
||||
props_getter = tool.Model.get_door_props
|
||||
gizmo_pref_name = "door"
|
||||
|
||||
@classmethod
|
||||
@@ -910,13 +866,11 @@ 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",
|
||||
)
|
||||
@@ -939,7 +893,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 = tool.Blender.get_addon_preferences()
|
||||
prefs = self.get_addon_prefs()
|
||||
door_gizmo_prefs = prefs.gizmos.door
|
||||
|
||||
door_type_visible = self.update_gizmo_visibility(
|
||||
|
||||
@@ -41,8 +41,187 @@ 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(
|
||||
@@ -941,7 +1120,6 @@ class SelectBoolean(Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# TODO: merge with ProfileDecorator?
|
||||
class DecorationsHandler:
|
||||
installed = None
|
||||
|
||||
@@ -951,6 +1129,7 @@ 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):
|
||||
@@ -959,15 +1138,46 @@ class DecorationsHandler:
|
||||
except ValueError:
|
||||
pass
|
||||
cls.installed = None
|
||||
uninstall_decoration_cache_handlers()
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
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
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
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.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:
|
||||
@@ -1039,23 +1249,20 @@ 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:
|
||||
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]
|
||||
|
||||
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
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)
|
||||
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"),
|
||||
)
|
||||
|
||||
if "HalfSpaceSolid" in obj.name:
|
||||
# Arrow shape
|
||||
@@ -1069,7 +1276,4 @@ 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_batch("LINES", verts, color, edges)
|
||||
|
||||
if obj.mode != "EDIT":
|
||||
bm.free()
|
||||
self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
|
||||
|
||||
@@ -75,6 +75,7 @@ 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"]},
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""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
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
@@ -193,6 +195,32 @@ 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:
|
||||
@@ -1631,6 +1659,118 @@ 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")
|
||||
@@ -1762,3 +1902,65 @@ 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
|
||||
|
||||
@@ -34,6 +34,7 @@ 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
|
||||
@@ -92,7 +93,6 @@ 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,66 +406,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_railing"
|
||||
bl_label = "Enable Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
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."""
|
||||
|
||||
def _execute(self, 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"]
|
||||
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
|
||||
|
||||
# required since we could load pset from .ifc and BIMRailingProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data: dict) -> None:
|
||||
update_bbim_railing_pset(element, data)
|
||||
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_railing_modifier_ifc_data(context)
|
||||
|
||||
|
||||
class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_railing"
|
||||
bl_label = "Cancel Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
@classmethod
|
||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_railing_modifier_bmesh(context)
|
||||
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_railing"
|
||||
bl_label = "Finish Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_railing"
|
||||
bl_label = "Enable Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
return self._enable_targets(context)
|
||||
|
||||
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
|
||||
class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_railing"
|
||||
bl_label = "Cancel Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
update_bbim_railing_pset(element, railing_data)
|
||||
update_railing_modifier_ifc_data(context)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_railing"
|
||||
bl_label = "Finish Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -34,6 +34,7 @@ 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
|
||||
@@ -608,61 +609,59 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tool.Model.add_body_representation(obj)
|
||||
|
||||
|
||||
class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
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."""
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
|
||||
# required since we could load pset from .ifc and BIMRoofProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
pset_name = "BBIM_Roof"
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_roof(element)
|
||||
|
||||
class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_roof_props(obj)
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data: dict) -> None:
|
||||
update_bbim_roof_pset(element, data)
|
||||
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(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)
|
||||
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
return self._enable_targets(context)
|
||||
|
||||
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
|
||||
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
update_bbim_roof_pset(element, roof_data)
|
||||
update_roof_modifier_ifc_data(context)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import json
|
||||
|
||||
@@ -262,7 +264,6 @@ 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)
|
||||
|
||||
@@ -272,6 +273,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
# update IfcStairFlight properties
|
||||
update_ifc_stair_props(obj)
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -428,7 +430,7 @@ class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = "get_stair_props"
|
||||
props_getter = tool.Model.get_stair_props
|
||||
type_literal = tool.Model.StairType
|
||||
type_attr = "stair_type"
|
||||
skip_element_check = True
|
||||
@@ -578,7 +580,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
]
|
||||
|
||||
# Metadata-driven dispatch for props and preferences
|
||||
props_getter = "get_stair_props"
|
||||
props_getter = tool.Model.get_stair_props
|
||||
gizmo_pref_name = "stair"
|
||||
|
||||
@classmethod
|
||||
@@ -591,14 +593,12 @@ 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,29 +608,23 @@ 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") -> None:
|
||||
"""Update stair-specific lock and tread count gizmos."""
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
self.update_lock_gizmo(mw, props, billboard_rot)
|
||||
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)
|
||||
self.update_tread_lock_gizmo(props)
|
||||
self.update_tread_count_gizmos(props)
|
||||
|
||||
def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
|
||||
"""Update lock gizmo visibility, color, and position."""
|
||||
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Update lock gizmo color and visibility. Positioning is handled
|
||||
per-frame by the dimension-positioning hook."""
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
|
||||
return # Hidden, skip positioning
|
||||
|
||||
return # Hidden, skip color update
|
||||
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"):
|
||||
@@ -650,11 +644,11 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
) -> None:
|
||||
"""Update dimension gizmo positions based on camera view direction."""
|
||||
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
total_run = props.get_total_run()
|
||||
riser_height = props.get_riser_height()
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -303,6 +304,8 @@ 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)
|
||||
@@ -322,22 +325,61 @@ 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:
|
||||
row.label(text=str(prop_value_item))
|
||||
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))
|
||||
else:
|
||||
row.label(text=prop_name)
|
||||
row.label(text=str(prop_value))
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
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)
|
||||
row.label(text=str(prop_value))
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
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
@@ -39,6 +39,7 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -482,90 +483,53 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
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):
|
||||
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"}
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
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"}
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class FinishEditingWindow(_WindowEditMixin, 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"}
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
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"}
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingWindow(_WindowEditMixin, 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"}
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
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"}
|
||||
return self._enable_targets(context)
|
||||
|
||||
|
||||
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -594,8 +558,8 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixi
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "is_window"
|
||||
props_getter = "get_window_props"
|
||||
element_checker = tool.Parametric.is_window
|
||||
props_getter = tool.Model.get_window_props
|
||||
type_literal = tool.Model.WindowType
|
||||
type_attr = "window_type"
|
||||
|
||||
@@ -781,7 +745,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
|
||||
]
|
||||
|
||||
props_getter = "get_window_props"
|
||||
props_getter = tool.Model.get_window_props
|
||||
gizmo_pref_name = "window"
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -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)
|
||||
cls.draw_regen_operations(row, ui_context)
|
||||
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
@@ -962,20 +962,14 @@ class EditObjectUI:
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
def draw_regen_operations(cls, row):
|
||||
custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id
|
||||
|
||||
def draw_regen_operations(cls, row, ui_context):
|
||||
if AuthoringData.data["is_regenable_element"]:
|
||||
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
|
||||
description = "Recalculate Element Geometry\nHotkey: S G"
|
||||
op.hotkey = "S_G"
|
||||
op.description = description.strip()
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
|
||||
|
||||
if PortData.data["total_ports"] > 0:
|
||||
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()
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def draw_void(cls, context, row):
|
||||
@@ -1300,9 +1294,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.generate_space()
|
||||
return
|
||||
if self.active_material_usage == "LAYER2":
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
if element and tool.Model.has_underside_connection(element):
|
||||
bpy.ops.bim.regenerate_wall_to_underside()
|
||||
else:
|
||||
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,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import datetime
|
||||
import json
|
||||
@@ -61,6 +63,7 @@ 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
|
||||
@@ -1903,11 +1906,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()
|
||||
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)
|
||||
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)
|
||||
|
||||
def check(self, context):
|
||||
# ExportHelper is automatically adjusting suffix to `filename_ext`.
|
||||
@@ -1933,6 +1936,20 @@ 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")
|
||||
@@ -2001,7 +2018,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)}',
|
||||
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
|
||||
)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
|
||||
@@ -2011,7 +2028,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',
|
||||
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved{commit_suffix}',
|
||||
)
|
||||
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
|
||||
@@ -302,6 +302,15 @@ 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"}
|
||||
|
||||
|
||||
|
||||
@@ -118,6 +118,19 @@ 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")),
|
||||
@@ -221,24 +234,29 @@ class BIMStylesProperties(PropertyGroup):
|
||||
transparency: bpy.props.FloatProperty(
|
||||
name="Transparency", default=0.0, min=0.0, max=1.0, update=update_shader_graph
|
||||
)
|
||||
# TODO: do something on null?
|
||||
is_diffuse_colour_null: BoolProperty(name="Is Null")
|
||||
is_diffuse_colour_null: BoolProperty(name="Is Null", update=update_shader_graph)
|
||||
diffuse_colour_class: EnumProperty(
|
||||
items=[(x, x, "") for x in get_args(ColourClass)],
|
||||
name="Diffuse Colour Class",
|
||||
update=update_shader_graph,
|
||||
update=update_diffuse_colour,
|
||||
)
|
||||
diffuse_colour: bpy.props.FloatVectorProperty(
|
||||
name="Diffuse Colour", subtype="COLOR", default=(1, 1, 1), min=0.0, max=1.0, size=3, update=update_shader_graph
|
||||
name="Diffuse Colour",
|
||||
subtype="COLOR",
|
||||
default=(1, 1, 1),
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
size=3,
|
||||
update=update_diffuse_colour,
|
||||
)
|
||||
diffuse_colour_ratio: bpy.props.FloatProperty(
|
||||
name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_shader_graph
|
||||
name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_diffuse_colour
|
||||
)
|
||||
is_specular_colour_null: BoolProperty(name="Is Null")
|
||||
is_specular_colour_null: BoolProperty(name="Is Null", update=update_shader_graph)
|
||||
specular_colour_class: EnumProperty(
|
||||
items=[(x, x, "") for x in get_args(ColourClass)],
|
||||
name="Specular Colour Class",
|
||||
update=update_shader_graph,
|
||||
update=update_specular_colour,
|
||||
default="IfcNormalisedRatioMeasure",
|
||||
)
|
||||
specular_colour: bpy.props.FloatVectorProperty(
|
||||
@@ -248,7 +266,7 @@ class BIMStylesProperties(PropertyGroup):
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
size=3,
|
||||
update=update_shader_graph,
|
||||
update=update_specular_colour,
|
||||
)
|
||||
specular_colour_ratio: bpy.props.FloatProperty(
|
||||
name="Specular Ratio",
|
||||
@@ -256,16 +274,16 @@ class BIMStylesProperties(PropertyGroup):
|
||||
default=0.0,
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
update=update_shader_graph,
|
||||
update=update_specular_colour,
|
||||
)
|
||||
is_specular_highlight_null: BoolProperty(name="Is Null")
|
||||
is_specular_highlight_null: BoolProperty(name="Is Null", update=update_shader_graph)
|
||||
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_shader_graph,
|
||||
update=update_specular_highlight_value,
|
||||
)
|
||||
reflectance_method: EnumProperty(
|
||||
name="Reflectance Method",
|
||||
|
||||
@@ -151,7 +151,8 @@ class BIM_PT_type_attributes(Panel):
|
||||
row = layout.row(align=True)
|
||||
row.label(text=attribute["name"])
|
||||
value = get_display_value(attribute["value"])
|
||||
row.label(text=value)
|
||||
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
|
||||
op.key = "type." + attribute["name"]
|
||||
|
||||
|
||||
def add_object_button(self, context):
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Shared operator mixins for parametric-edit operators.
|
||||
|
||||
Edit-lifecycle mixins (Enable / Finish / Cancel):
|
||||
`FeatureModifierEditMixin` — door, window (BBIM_<Type> pset; nested
|
||||
lining/panel properties; Finish + Cancel route through
|
||||
``ifcopenshell.api.feature``).
|
||||
`PathPreservingEditMixin` — railing, roof (path_data preserved across
|
||||
edit; only general kwargs are user-editable).
|
||||
|
||||
Pattern selection (which approach a new feature should adopt):
|
||||
Every parametric edit lifecycle commits to one of three patterns. Pick by
|
||||
answering "does the feature share the Enable→Finish→Cancel shape that
|
||||
one of the existing mixins already encodes?":
|
||||
|
||||
A. Inherit one of the shared mixins below and route through
|
||||
`tool.Parametric.build_edit_lifecycle`:
|
||||
|
||||
- `FeatureModifierEditMixin` when the feature stores its pset as
|
||||
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
|
||||
and Finish must call a per-type `update_<type>_modifier_representation`.
|
||||
|
||||
- `PathPreservingEditMixin` when the feature's pset carries a
|
||||
`path_data` field that survives general-kwarg edits untouched, with
|
||||
a separate Enable/Finish/Cancel lifecycle for path editing itself.
|
||||
|
||||
B. Write a per-feature mixin that subclasses `ParametricEditMixinBase`
|
||||
and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`,
|
||||
then route through `build_edit_lifecycle`. Pick this when the
|
||||
feature's pset roundtrip or representation handling diverges from the
|
||||
shared mixins but the Enable→Finish→Cancel shape still fits.
|
||||
|
||||
C. Declare standalone Enable/Finish/Cancel Operator subclasses (no
|
||||
factory) when the feature's parameter-change logic is sufficiently
|
||||
unique that even a per-feature mixin would force optional hooks or
|
||||
dead branches. Such operators MUST call the matrix_world drift
|
||||
helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish,
|
||||
`tool.Geometry.restore_or_rebaseline_placement` on Cancel) — the
|
||||
drift contract is enforced uniformly regardless of which pattern the
|
||||
operators adopt.
|
||||
|
||||
The authoritative list of registered parametric types — and which use
|
||||
`build_edit_lifecycle` vs. standalone operators — lives in
|
||||
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
|
||||
contract tests 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
|
||||
+112
-31
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import os
|
||||
import platform
|
||||
@@ -380,6 +382,76 @@ 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."""
|
||||
|
||||
@@ -391,12 +463,14 @@ 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):
|
||||
@@ -849,49 +923,56 @@ 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
|
||||
|
||||
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)
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
|
||||
)
|
||||
|
||||
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.window import GizmoWindowEdition
|
||||
|
||||
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)
|
||||
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
|
||||
|
||||
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
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)
|
||||
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"}),
|
||||
)
|
||||
|
||||
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "occurrence_name_style")
|
||||
|
||||
@@ -93,6 +93,8 @@ 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()
|
||||
@@ -107,6 +109,7 @@ 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):
|
||||
|
||||
@@ -15,10 +15,13 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
@@ -31,6 +34,24 @@ 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],
|
||||
@@ -140,23 +161,73 @@ 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_obj: bpy.types.Object,
|
||||
slab_objs: list[bpy.types.Object],
|
||||
wall_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
|
||||
return # Nothing to clip?
|
||||
slab = ifc.get_entity(slab_obj)
|
||||
# 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 = []
|
||||
for obj in wall_objs:
|
||||
if ifc.is_moved(obj):
|
||||
geometry.run_edit_object_placement(obj=obj)
|
||||
wall = ifc.get_entity(obj)
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
model.connect_wall_to_slab(wall, slab)
|
||||
model.reload_body_representation(wall_objs)
|
||||
# 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)
|
||||
|
||||
|
||||
class RequireTwoWallsError(Exception):
|
||||
@@ -173,3 +244,438 @@ 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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
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
|
||||
@@ -67,7 +67,8 @@ 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:
|
||||
collector.assign(ifc.get_object(element))
|
||||
if obj := ifc.get_object(element):
|
||||
collector.assign(obj)
|
||||
|
||||
|
||||
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -415,6 +415,17 @@ 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
|
||||
@@ -445,8 +456,10 @@ 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
|
||||
@@ -668,6 +681,9 @@ 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
|
||||
@@ -683,6 +699,7 @@ 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
|
||||
|
||||
|
||||
@@ -776,6 +793,12 @@ 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
|
||||
@@ -859,7 +882,6 @@ 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
|
||||
@@ -873,7 +895,6 @@ 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
|
||||
|
||||
@@ -1017,6 +1038,8 @@ 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
|
||||
@@ -1137,6 +1160,8 @@ 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
|
||||
@@ -1203,6 +1228,42 @@ 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
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# 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
|
||||
@@ -37,6 +38,7 @@ 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
|
||||
@@ -51,6 +53,7 @@ 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
|
||||
@@ -63,6 +66,7 @@ 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
|
||||
@@ -72,4 +76,5 @@ 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
|
||||
|
||||
@@ -205,6 +205,27 @@ 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
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# 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
|
||||
+331
-148
@@ -15,12 +15,13 @@
|
||||
#
|
||||
# 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
|
||||
@@ -28,7 +29,7 @@ import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
import types
|
||||
from collections.abc import Callable, Generator, Iterable, Sequence, Sized
|
||||
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
|
||||
from datetime import datetime
|
||||
from functools import cache, lru_cache
|
||||
from pathlib import Path
|
||||
@@ -45,7 +46,6 @@ 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,6 +97,19 @@ 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")
|
||||
@@ -415,6 +428,189 @@ 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 0–1 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:
|
||||
@@ -484,9 +680,13 @@ 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
|
||||
assert context.screen
|
||||
for area in context.screen.areas:
|
||||
screen = getattr(context, "screen", None)
|
||||
if screen is None:
|
||||
return
|
||||
for area in screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
|
||||
@@ -635,10 +835,11 @@ 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)
|
||||
@@ -646,6 +847,7 @@ 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:
|
||||
@@ -1130,6 +1332,74 @@ 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
|
||||
@@ -1137,20 +1407,18 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
:return: True if an action was taken, False otherwise
|
||||
"""
|
||||
if cls.is_roof(element):
|
||||
if cls.is_editing_roof_parameters(obj):
|
||||
bpy.ops.bim.finish_editing_roof()
|
||||
# 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)
|
||||
bpy.ops.bim.enable_editing_roof_path()
|
||||
elif cls.is_railing(element):
|
||||
if cls.is_editing_railing_parameters(obj):
|
||||
bpy.ops.bim.finish_editing_railing()
|
||||
elif tool.Parametric.is_railing(element):
|
||||
if tool.Parametric.RAILING.is_editing(obj):
|
||||
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
|
||||
bpy.ops.bim.enable_editing_railing_path()
|
||||
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()
|
||||
elif feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.finish_op)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
@@ -1161,68 +1429,80 @@ 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 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()
|
||||
elif feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.cancel_op)
|
||||
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, ("IfcRailing", "IfcRailingType"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(
|
||||
obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType")
|
||||
)
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Railing")
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Roof")
|
||||
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"
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Window")
|
||||
def is_pipe_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcPipeSegment")
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Door")
|
||||
def is_duct_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcDuctSegment")
|
||||
|
||||
@classmethod
|
||||
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):
|
||||
def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
return props.is_editing_path
|
||||
|
||||
@@ -1231,107 +1511,10 @@ 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:
|
||||
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
|
||||
feature = tool.Parametric.find_for_element(element)
|
||||
return bool(feature and feature.has_non_editable_path)
|
||||
|
||||
class Attribute:
|
||||
@classmethod
|
||||
|
||||
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
import bmesh
|
||||
@@ -45,6 +46,13 @@ 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:
|
||||
@@ -996,3 +1004,106 @@ 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,
|
||||
)
|
||||
|
||||
@@ -135,6 +135,7 @@ 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:
|
||||
|
||||
@@ -1756,6 +1756,10 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# 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}")
|
||||
@@ -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
|
||||
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.geometry.prop import (
|
||||
@@ -115,10 +115,42 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
cache = IfcStore.get_cache()
|
||||
# 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
|
||||
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:
|
||||
@@ -225,7 +257,13 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
break
|
||||
mesh = obj.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
|
||||
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
|
||||
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)
|
||||
@@ -389,6 +427,29 @@ 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'.
|
||||
@@ -788,6 +849,15 @@ 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
|
||||
@@ -1093,11 +1163,16 @@ 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 (
|
||||
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"))
|
||||
):
|
||||
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"):
|
||||
return item
|
||||
return None
|
||||
|
||||
@@ -1154,6 +1229,53 @@ 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)
|
||||
@@ -1205,11 +1327,27 @@ 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
|
||||
|
||||
@@ -2132,8 +2270,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
|
||||
new_active_obj = None
|
||||
# Track decompositions so they can be recreated after the operation
|
||||
decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate)
|
||||
connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate)
|
||||
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)
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
|
||||
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
||||
|
||||
@@ -2155,10 +2296,7 @@ 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.
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(
|
||||
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False
|
||||
)
|
||||
cls.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
new_obj = obj.copy()
|
||||
temp_data = None
|
||||
@@ -2212,7 +2350,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.Blender.Modifier.Array.get_all_children_objects(new):
|
||||
for child in tool.Array.get_all_children_objects(new):
|
||||
child.select_set(True)
|
||||
|
||||
# TODO: add new array children to recreate their decomposition too
|
||||
@@ -2240,10 +2378,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
|
||||
# Remove connections with old objects and recreates paths
|
||||
cls.remove_old_connections(old_to_new)
|
||||
tool.Root.recreate_connections(connection_relationships, old_to_new)
|
||||
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
|
||||
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
|
||||
|
||||
# Recreate decompositions
|
||||
tool.Root.recreate_decompositions(decomposition_relationships, old_to_new)
|
||||
tool.Duplicate.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()
|
||||
@@ -2308,8 +2447,8 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
continue
|
||||
|
||||
array_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))
|
||||
for modifier_data in tool.Array.get_modifiers_data(array_parent):
|
||||
children = set(tool.Array.get_children_objects(modifier_data))
|
||||
if children.issubset(selected_objects):
|
||||
modifier_data["children"] = []
|
||||
array_data.append(modifier_data)
|
||||
|
||||
+327
-41
@@ -15,12 +15,14 @@
|
||||
#
|
||||
# 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 Iterable, Sequence
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from copy import deepcopy
|
||||
from math import atan, cos, degrees, pi, radians
|
||||
from typing import (
|
||||
@@ -37,9 +39,11 @@ 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
|
||||
@@ -58,6 +62,7 @@ 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_
|
||||
@@ -77,6 +82,7 @@ if TYPE_CHECKING:
|
||||
BIMRoofProperties,
|
||||
BIMStairProperties,
|
||||
BIMSverchokProperties,
|
||||
BIMWallProperties,
|
||||
BIMWindowProperties,
|
||||
)
|
||||
|
||||
@@ -98,6 +104,10 @@ 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]
|
||||
@@ -123,6 +133,35 @@ 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)):
|
||||
@@ -312,6 +351,8 @@ 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"):
|
||||
@@ -792,7 +833,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 = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return []
|
||||
booleans = []
|
||||
@@ -804,6 +845,57 @@ 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
|
||||
@@ -813,10 +905,11 @@ class Model(bonsai.core.tool.Model):
|
||||
return []
|
||||
boolean_ids = json.loads(pset["Data"])
|
||||
if representation is None:
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return []
|
||||
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
||||
all_chain_booleans = cls.get_booleans(element, representation)
|
||||
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
|
||||
return booleans
|
||||
|
||||
@classmethod
|
||||
@@ -902,7 +995,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 = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
if body and any(
|
||||
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
|
||||
):
|
||||
@@ -1015,7 +1108,14 @@ 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:
|
||||
"""if no `array_data` is provided then an array will be removed from the element"""
|
||||
"""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 array_data is None:
|
||||
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
@@ -1059,8 +1159,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.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
tool.Array.set_children_lock_state(element, i, True)
|
||||
tool.Array.constrain_children_to_parent(element)
|
||||
|
||||
@classmethod
|
||||
def regenerate_array(
|
||||
@@ -1097,12 +1197,17 @@ 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:
|
||||
except (IndexError, RuntimeError, AssertionError):
|
||||
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)
|
||||
@@ -1139,14 +1244,24 @@ 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
|
||||
@@ -1159,6 +1274,112 @@ 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,
|
||||
@@ -1305,8 +1526,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) -> bool:
|
||||
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects())
|
||||
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))
|
||||
|
||||
@classmethod
|
||||
def get_selected_mesh_objects(cls) -> list[bpy.types.Object]:
|
||||
@@ -1355,8 +1576,7 @@ 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"""
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
|
||||
@classmethod
|
||||
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
|
||||
@@ -1388,7 +1608,7 @@ class Model(bonsai.core.tool.Model):
|
||||
if not obj.data:
|
||||
continue
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
@@ -1505,6 +1725,10 @@ 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,
|
||||
@@ -1756,7 +1980,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.Ifc.get_all_element_occurrences(element)}
|
||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||
|
||||
voided_objs = set()
|
||||
has_replaced_opening_representation = False
|
||||
@@ -1898,7 +2122,9 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
|
||||
# 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.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||
|
||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
||||
@@ -2126,7 +2352,7 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION)
|
||||
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||
|
||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
||||
@@ -2345,6 +2571,12 @@ 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)))
|
||||
@@ -2379,12 +2611,15 @@ 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
|
||||
if (obj.matrix_world @ normal).z >= -0.5:
|
||||
world_normal_z = (obj.matrix_world @ normal).z
|
||||
if 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)):
|
||||
@@ -2397,6 +2632,7 @@ 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
|
||||
@@ -2410,17 +2646,53 @@ class Model(bonsai.core.tool.Model):
|
||||
min_z = min(zs)
|
||||
max_z = max(zs)
|
||||
|
||||
operand = None
|
||||
if (z := max_z - min_z) and not np.isclose(z, 0.0):
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
ifc_file = tool.Ifc.get()
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
|
||||
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))
|
||||
# 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()
|
||||
|
||||
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)
|
||||
# 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)
|
||||
|
||||
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
|
||||
if extrusion.Position:
|
||||
@@ -2437,10 +2709,9 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
extrusion.Depth = max_z / direction[2]
|
||||
|
||||
if operand:
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(
|
||||
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
|
||||
)
|
||||
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)
|
||||
tool.Model.mark_manual_booleans(wall, booleans)
|
||||
|
||||
@classmethod
|
||||
@@ -2693,6 +2964,20 @@ 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,
|
||||
@@ -2713,28 +2998,29 @@ 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)
|
||||
if tool.Ifc.is_moved(wall):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
|
||||
tool.Geometry.commit_placement_if_moved(wall)
|
||||
queue.add((element, wall))
|
||||
for rel in getattr(element, "ConnectedTo", []):
|
||||
obj = tool.Ifc.get_object(rel.RelatedElement)
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
queue.add((rel.RelatedElement, obj))
|
||||
for rel in getattr(element, "ConnectedFrom", []):
|
||||
obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
queue.add((rel.RelatingElement, obj))
|
||||
for element, wall in queue:
|
||||
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
|
||||
# Use layer custom offset
|
||||
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:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
# 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
|
||||
@@ -18,10 +18,12 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -74,6 +76,34 @@ 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":
|
||||
|
||||
@@ -373,26 +373,42 @@ 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 = 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]]
|
||||
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])
|
||||
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:
|
||||
@@ -404,10 +420,16 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
snap_threshold = 10.0
|
||||
|
||||
for i, point in enumerate(verts_2d):
|
||||
if not point:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - point).length
|
||||
# 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
|
||||
if distance <= snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
@@ -799,6 +821,30 @@ 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,
|
||||
@@ -813,35 +859,45 @@ 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
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
if closest_wf_point:
|
||||
hit_obj = closest_wf_point["object"]
|
||||
hit = closest_wf_point["point"]
|
||||
face_index = None
|
||||
# Rough distance - object origin to ray origin
|
||||
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
|
||||
|
||||
else:
|
||||
# Solid objects
|
||||
# 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:
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
@@ -855,14 +911,47 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# Label snaps from the closest object
|
||||
if closest_obj is not None:
|
||||
@@ -936,12 +1025,19 @@ class SnapObj:
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
self.__class__.all.append(self)
|
||||
self.obj = obj
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self.root = None
|
||||
self._bvh_built = False
|
||||
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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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,
|
||||
}
|
||||
@@ -90,6 +90,32 @@ 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":
|
||||
|
||||
@@ -203,6 +203,11 @@ 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
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
|
||||
@@ -26,6 +27,7 @@ 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
|
||||
|
||||
@@ -35,12 +37,29 @@ import bonsai.core.root
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import import_ifc
|
||||
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
|
||||
|
||||
# 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.
|
||||
|
||||
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:
|
||||
@@ -81,7 +100,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.Model.sync_object_ifc_position(obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
|
||||
mep_element = tool.Ifc.get_entity(obj)
|
||||
bbox = tool.Blender.get_object_bounding_box(obj)
|
||||
@@ -162,12 +181,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) -> ifcopenshell.entity_instance:
|
||||
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
element = port.ContainedIn[0].RelatedElement
|
||||
else:
|
||||
element = port.Nests[0].RelatingObject
|
||||
return element
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
|
||||
@@ -280,31 +299,42 @@ 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
|
||||
@@ -458,6 +488,72 @@ 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:
|
||||
|
||||
@@ -199,8 +199,8 @@ class Unit(bonsai.core.tool.Unit):
|
||||
if inches is None:
|
||||
inches = 0
|
||||
|
||||
# If feet is negative, inches should also be negative (subtractive)
|
||||
if feet < 0:
|
||||
# If feet is negative (including -0), inches should also be negative (subtractive)
|
||||
if math.copysign(1, feet) < 0:
|
||||
inches = -inches
|
||||
|
||||
# Convert to meters
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# 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,6 +51,62 @@ 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
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
+31
-3
@@ -17,18 +17,46 @@
|
||||
# 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`
|
||||
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
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
argv = [__file__]
|
||||
|
||||
if "--" in sys.argv:
|
||||
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.
|
||||
i = sys.argv.index("--")
|
||||
argv += sys.argv[i + 1 :]
|
||||
|
||||
|
||||
@@ -285,6 +285,32 @@ 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
|
||||
|
||||
@@ -285,6 +285,7 @@ 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
|
||||
@@ -673,6 +674,129 @@ 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
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU 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)
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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
|
||||
@@ -0,0 +1,521 @@
|
||||
# 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 == {}
|
||||
@@ -0,0 +1,178 @@
|
||||
# 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."
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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 ``parametric_lifecycle.resync_parametric_drafts_after_undo``.
|
||||
|
||||
Blender's undo restores PropertyGroup field values but does not refire
|
||||
their ``update`` callbacks, so the preview mesh of an in-progress
|
||||
parametric draft desyncs from the gizmo dimension widget after Ctrl+Z.
|
||||
The resync helper walks active drafts and re-runs the per-type
|
||||
regenerator to bring preview back in line with the (restored) draft
|
||||
state. This file pins the dispatch contract."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import parametric_lifecycle
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def test_undo_regenerators_target_registered_parametric_types():
|
||||
"""Every entry in ``UNDO_REGENERATORS`` must name a real parametric
|
||||
type. A typo would silently no-op on Ctrl+Z, restoring the desync
|
||||
this helper is meant to prevent."""
|
||||
registered_names = {f.name for f in tool.Parametric.EDIT_TYPES}
|
||||
unknown = set(parametric_lifecycle.UNDO_REGENERATORS) - registered_names
|
||||
assert not unknown, f"UNDO_REGENERATORS keys {unknown} are not in tool.Parametric.EDIT_TYPES"
|
||||
|
||||
|
||||
def test_resync_skips_objects_not_in_parametric_edit():
|
||||
"""Objects with no active parametric edit must not trigger any
|
||||
regenerator — the helper is called from undo_post which fires on
|
||||
every undo, including undos that touch zero parametric drafts."""
|
||||
captured = []
|
||||
|
||||
def fake_dispatch(obj):
|
||||
captured.append(obj)
|
||||
|
||||
with patch.dict(parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_dispatch}, clear=False), patch.object(
|
||||
tool.Parametric, "is_object_editing", return_value=None
|
||||
):
|
||||
parametric_lifecycle.resync_parametric_drafts_after_undo()
|
||||
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_resync_dispatches_to_registered_regenerator_for_editing_object():
|
||||
"""When an object is in parametric edit and its type has a registered
|
||||
regenerator, the regenerator must run with that object as the sole
|
||||
arg. This is the load-bearing branch: preview mesh re-renders from
|
||||
current props, so the gizmo and preview re-sync."""
|
||||
captured = []
|
||||
|
||||
def fake_wall_regenerator(obj):
|
||||
captured.append(obj)
|
||||
|
||||
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
|
||||
fake_feature.name = "wall"
|
||||
|
||||
obj = bpy.data.objects.new("test_wall_obj", bpy.data.meshes.new("test_wall_mesh"))
|
||||
try:
|
||||
with patch.dict(
|
||||
parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_wall_regenerator}, clear=False
|
||||
), patch.object(tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None):
|
||||
parametric_lifecycle.resync_parametric_drafts_after_undo()
|
||||
finally:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
assert captured == [obj]
|
||||
|
||||
|
||||
def test_resync_skips_editing_object_whose_type_has_no_regenerator():
|
||||
"""A parametric type without an ``UNDO_REGENERATORS`` entry (door /
|
||||
window / array — IFC-derived preview, no desync) must not raise; the
|
||||
helper silently skips it."""
|
||||
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
|
||||
fake_feature.name = "door" # door has no entry in UNDO_REGENERATORS
|
||||
|
||||
obj = bpy.data.objects.new("test_door_obj", bpy.data.meshes.new("test_door_mesh"))
|
||||
try:
|
||||
with patch.object(
|
||||
tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None
|
||||
):
|
||||
parametric_lifecycle.resync_parametric_drafts_after_undo()
|
||||
finally:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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 contract: every wall gizmo group hides while a parametric-edit
|
||||
preview is active.
|
||||
|
||||
Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup``
|
||||
subclasses rather than naming them — adding a new wall gizmo group automatically
|
||||
joins the test. The test then asserts the BEHAVIOUR (poll returns False when
|
||||
``preview_base.any_preview_active`` is True) without pinning the name of the
|
||||
helper function the gizmo uses internally to enforce it."""
|
||||
|
||||
import inspect
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
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 _wall_gizmo_groups():
|
||||
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
|
||||
locally (skip imported references). Returns a list of (name, cls) tuples.
|
||||
|
||||
A gizmo group whose ``poll`` legitimately needs to fire WHILE a preview
|
||||
is active — i.e. it IS the preview's own gizmo group — is excluded by
|
||||
convention: classes whose bl_idname references the preview surface
|
||||
(``preview`` in the idname) are the preview-owner exception."""
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
# Local definitions only — skip re-exports / aliases.
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
# Preview-owner exception: the gizmo group that drives a preview
|
||||
# itself must remain visible while its preview is active, so a
|
||||
# "no preview active" gate would self-block it. The bl_idname
|
||||
# contains the substring 'preview' for these groups by Bonsai
|
||||
# convention (e.g. OBJECT_GGT_bim_wall_fillet_preview).
|
||||
bl_idname = getattr(obj, "bl_idname", "") or ""
|
||||
if "preview" in bl_idname.lower():
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestWallGizmoGroupsHideDuringPreview:
|
||||
"""Behaviour contract: a parametric-edit preview is the only interactive
|
||||
surface in the viewport, so every sister wall gizmo must self-hide via
|
||||
its poll. The test exercises this BEHAVIOUR — when ``any_preview_active``
|
||||
reports True, every wall gizmo's poll returns False — without pinning
|
||||
the helper function name each poll uses internally."""
|
||||
|
||||
def test_discovery_finds_wall_gizmo_groups(self):
|
||||
"""Sanity check: at least one wall gizmo group is found. If this fails,
|
||||
the discovery walk drifted out of sync with the module structure (e.g.
|
||||
wall gizmo groups got moved to a separate file)."""
|
||||
groups = _wall_gizmo_groups()
|
||||
assert groups, "Expected at least one wall GizmoGroup subclass in wall.py — discovery walk broke?"
|
||||
|
||||
def test_every_wall_gizmo_hides_when_a_preview_is_active(self):
|
||||
"""For each discovered wall gizmo group, mock ``any_preview_active`` to
|
||||
True and call ``poll(bpy.context)``. Every poll must return False —
|
||||
any True is a poll that wouldn't hide during a fillet/bend preview,
|
||||
leaving the user with two competing icon stacks on the same selection."""
|
||||
groups = _wall_gizmo_groups()
|
||||
offenders = []
|
||||
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=True):
|
||||
for name, cls in groups:
|
||||
poll = getattr(cls, "poll", None)
|
||||
if poll is None:
|
||||
# Inherits poll from a mixin / base — the base poll's gating
|
||||
# is covered separately. Skip rather than crash.
|
||||
continue
|
||||
try:
|
||||
result = poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with preview active"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall gizmo polls that don't gate on any_preview_active "
|
||||
"(or raise instead of returning False): "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Hide sister gizmos during previews so the preview is the only "
|
||||
"interactive surface in the viewport. The conventional path is to "
|
||||
"early-return from poll when preview_base.any_preview_active(context) "
|
||||
"is True."
|
||||
)
|
||||
|
||||
|
||||
class TestBaseParametricGizmoPollHidesDuringPreview:
|
||||
"""Mirror of the wall-specific test for the cross-feature parametric
|
||||
framework: door / window / stair / roof / railing / array all inherit
|
||||
``BaseParametricGizmoGroup``. Its poll must also short-circuit on
|
||||
``any_preview_active`` so sister features behave consistently with walls."""
|
||||
|
||||
def test_base_parametric_poll_returns_false_when_a_preview_is_active(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
# The base poll requires an active selected object before checking the
|
||||
# preview gate. Mock both the selected-object check (return a sentinel)
|
||||
# AND the gate so the test exercises ONLY the preview short-circuit.
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.model.preview_base.any_preview_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseParametricGizmoGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestModulePathIsFindable:
|
||||
"""If wall.py is split across multiple modules (e.g. wall_gizmos.py),
|
||||
update ``_wall_gizmo_groups`` to walk each. This sanity check fails first
|
||||
so the diagnostic message is obvious."""
|
||||
|
||||
def test_wall_module_resolves(self):
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
assert inspect.ismodule(wall_mod)
|
||||
@@ -0,0 +1,290 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit tests for the poll() preconditions of wall billboarding gizmo groups.
|
||||
|
||||
These tests patch ``tool.Blender`` / ``tool.Ifc`` / ``tool.Model`` so the poll
|
||||
logic can be exercised without a real IFC fixture. Each test pins one of the
|
||||
gates ``poll()`` walks, so any silent regression in the gate order or in the
|
||||
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
@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 _make_context(active, selected):
|
||||
"""Build a minimal ``context`` stub with the two attributes ``poll()`` reads."""
|
||||
return SimpleNamespace(active_object=active, selected_objects=list(selected))
|
||||
|
||||
|
||||
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
|
||||
"""Return a stack of patches that simulate one selection / IFC state for poll().
|
||||
|
||||
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
|
||||
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
|
||||
so the test only depends on the predicate ordering in poll()."""
|
||||
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
|
||||
|
||||
entity_map = {}
|
||||
usage_map = {}
|
||||
# active_element/other_element are matched by object identity from the selected set
|
||||
if len(selected) == 2:
|
||||
entity_map[id(selected[0])] = active_element
|
||||
entity_map[id(selected[1])] = other_element
|
||||
usage_map[id(active_element)] = active_usage
|
||||
usage_map[id(other_element)] = other_usage
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def get_usage_type(element):
|
||||
return usage_map.get(id(element))
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
return [
|
||||
patch.object(tool.Blender, "get_addon_preferences", return_value=prefs),
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
|
||||
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
|
||||
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
|
||||
]
|
||||
|
||||
|
||||
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
|
||||
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
|
||||
|
||||
slab_obj = object()
|
||||
wall_obj = object()
|
||||
active = slab_obj if active_is_in_selected else object()
|
||||
if len_override is None:
|
||||
selected = [slab_obj, wall_obj]
|
||||
else:
|
||||
selected = [object() for _ in range(len_override)]
|
||||
if active_is_in_selected and selected:
|
||||
active = selected[0]
|
||||
|
||||
slab_element = object() if active_has_entity else None
|
||||
wall_element = object()
|
||||
|
||||
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
return GizmoWallExtendVertically.poll(_make_context(active, selected))
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
def test_poll_accepts_layer3_active_with_layer2_other():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_gizmo_toggle_off():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_selection_count_is_not_two():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_has_no_ifc_entity():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True,
|
||||
active_is_in_selected=True,
|
||||
len_override=None,
|
||||
active_usage="LAYER3",
|
||||
other_usage="LAYER2",
|
||||
active_has_entity=False,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_layer3():
|
||||
# A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo
|
||||
# owns that case, and extend_walls_to_underside expects the slab to be active.
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
# Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected.
|
||||
assert (
|
||||
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2")
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_other_is_not_layer2_wall():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3"
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# Normalises both ConnectedTo and ConnectedFrom orientations to (other, self_ct,
|
||||
# other_ct) so callers always read "self first" regardless of which side of the
|
||||
# rel this wall was authored on. Non-wall partners and malformed (None) refs are
|
||||
# filtered out so per-frame gizmo positioning survives partial IFC state.
|
||||
|
||||
|
||||
def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConnectsPathElements"):
|
||||
"""Build a stub IfcRelConnectsPathElements for inverse-walk tests."""
|
||||
return SimpleNamespace(
|
||||
is_a=lambda name, _k=kind: name == _k,
|
||||
RelatingElement=relating,
|
||||
RelatedElement=related,
|
||||
RelatingConnectionType=relating_ct,
|
||||
RelatedConnectionType=related_ct,
|
||||
)
|
||||
|
||||
|
||||
def _run_iter_path_connections(elem, *, is_wall_predicate=lambda _e: True):
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.wall import _iter_path_connections
|
||||
|
||||
with patch.object(tool.Blender.Modifier, "is_wall", side_effect=is_wall_predicate):
|
||||
return _iter_path_connections(elem)
|
||||
|
||||
|
||||
def test_iter_path_connections_empty_inverses_yields_nothing():
|
||||
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == []
|
||||
|
||||
|
||||
def test_iter_path_connections_connected_to_orientation_is_self_first():
|
||||
# Self is the rel's RelatingElement → its connection type is RelatingConnectionType.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_connected_from_orientation_is_self_first():
|
||||
# Self is the rel's RelatedElement → its connection type is RelatedConnectionType.
|
||||
# The helper must FLIP the tuple so callers still see (other, self_ct, other_ct).
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel = _make_path_rel(relating=other, related=self_elem, relating_ct="ATSTART", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[rel])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_skips_non_path_rels():
|
||||
# IfcRelAggregates, IfcRelContainedInSpatialStructure, etc. share the
|
||||
# ConnectedTo/ConnectedFrom inverse arrays — only IfcRelConnectsPathElements
|
||||
# carries the per-end connection-type semantics we care about.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
non_path = _make_path_rel(
|
||||
relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND", kind="IfcRelAggregates"
|
||||
)
|
||||
path = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
|
||||
elem = SimpleNamespace(ConnectedTo=[non_path, path], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_skips_non_wall_partners():
|
||||
# Walls may path-connect to non-wall elements (columns, beams). The single-
|
||||
# wall unjoin gizmo only surfaces wall-to-wall joins to match the existing
|
||||
# two-wall gizmo's scope.
|
||||
self_elem = object()
|
||||
wall_partner = object()
|
||||
non_wall_partner = object()
|
||||
rel_wall = _make_path_rel(relating=self_elem, related=wall_partner, relating_ct="ATEND", related_ct="ATSTART")
|
||||
rel_non_wall = _make_path_rel(
|
||||
relating=self_elem, related=non_wall_partner, relating_ct="ATEND", related_ct="ATSTART"
|
||||
)
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_wall, rel_non_wall], ConnectedFrom=[])
|
||||
result = _run_iter_path_connections(elem, is_wall_predicate=lambda e: e is wall_partner)
|
||||
assert result == [(wall_partner, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_tolerates_none_partner_refs():
|
||||
# Malformed / partial IFC files can leave a rel's element ref unset.
|
||||
# Without a None guard, `Modifier.is_wall(None)` would raise on
|
||||
# `None.is_a(...)` mid-frame and silently break the gizmo group.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel_none = _make_path_rel(relating=self_elem, related=None, relating_ct="ATEND", related_ct="ATSTART")
|
||||
rel_ok = _make_path_rel(relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_none, rel_ok], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATSTART", "ATEND")]
|
||||
|
||||
|
||||
def test_iter_path_connections_walks_both_inverses_in_order():
|
||||
# A wall can sit on both sides of different path rels (e.g. authored once
|
||||
# as the RelatingElement, once as the RelatedElement). The helper walks
|
||||
# ConnectedTo first, then ConnectedFrom — pinning the order so callers can
|
||||
# depend on it for icon-slot allocation.
|
||||
self_elem = object()
|
||||
p1 = object()
|
||||
p2 = object()
|
||||
rel_to = _make_path_rel(relating=self_elem, related=p1, relating_ct="ATSTART", related_ct="ATSTART")
|
||||
rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from])
|
||||
assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")]
|
||||
@@ -0,0 +1,87 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Regression tests for the post-IFC-commit refresh path that re-syncs the
|
||||
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
|
||||
gizmo geometry cache.
|
||||
|
||||
Bug repro before the fix: hotkey operators that edited the active wall in
|
||||
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
|
||||
fired ``active_object_callback`` (no selection change), so the header H/L/A
|
||||
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
|
||||
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
|
||||
per-gizmo-group geometry cache. The fix wires both refreshes through
|
||||
``tool.Parametric.refresh_post_commit`` and calls it from every
|
||||
``tool.Ifc.Operator`` epilogue."""
|
||||
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
@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_refresh_post_commit_bumps_generation_and_resyncs_header():
|
||||
"""``refresh_post_commit`` must bump the generation counter and call
|
||||
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
|
||||
import bonsai.bim.handler as handler
|
||||
from bonsai import tool
|
||||
|
||||
before = tool.Parametric.get_geom_generation()
|
||||
with patch.object(handler, "update_bim_tool_props") as mock_resync:
|
||||
tool.Parametric.refresh_post_commit()
|
||||
assert tool.Parametric.get_geom_generation() == before + 1
|
||||
mock_resync.assert_called_once()
|
||||
|
||||
|
||||
def test_geom_generation_invalidates_wall_geom_cache():
|
||||
"""Bumping the generation must cause ``_get_wall_geom_cached`` to drop its
|
||||
stored entries on the next read, even when the same gizmo group instance
|
||||
and the same wall object are reused (the case Blender's
|
||||
``GizmoGroup.refresh()`` does not cover)."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
class _FakeGroup:
|
||||
pass
|
||||
|
||||
group = _FakeGroup()
|
||||
fake_obj = types.SimpleNamespace(name="Wall/W001")
|
||||
sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
|
||||
sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
|
||||
|
||||
with patch.object(tool.Wall, "read_geometry", side_effect=[sentinel_a, sentinel_b]):
|
||||
first = wall_mod._get_wall_geom_cached(group, fake_obj)
|
||||
assert first is sentinel_a
|
||||
# Same call without a generation bump must hit the cache (no extra read).
|
||||
assert wall_mod._get_wall_geom_cached(group, fake_obj) is sentinel_a
|
||||
# Simulate an IFC commit: generation advances, cache must drop.
|
||||
tool.Parametric._geom_generation += 1
|
||||
second = wall_mod._get_wall_geom_cached(group, fake_obj)
|
||||
assert second is sentinel_b
|
||||
assert second is not first
|
||||
@@ -0,0 +1,377 @@
|
||||
# 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 tests for wall-split opening assignment when the cut passes
|
||||
through an opening.
|
||||
|
||||
Bug repro before the fix: when ``bpy.ops.bim.split_wall`` (Shift+K) cut a wall
|
||||
through an opening, ``DumbWallJoiner.split`` decided opening assignment using
|
||||
the opening's centre-point projected onto the wall axis. Any opening whose
|
||||
extent straddled the cut was therefore assigned to whichever side its centre
|
||||
sat on, leaving the neighbour wall with no void where the opening overlapped.
|
||||
|
||||
The fix replaces the single-point test with an axis-projected extent
|
||||
(``_opening_axis_extent``): an opening is removed from a wall only when its
|
||||
extent lies *entirely* outside that wall's portion of the axis. Straddling
|
||||
openings stay on both walls."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _fake_shape(verts_local, matrix_world_4x4):
|
||||
"""Build a stand-in for the ``shape`` object returned by
|
||||
``ifcopenshell.geom.create_shape``. ``get_vertices`` and
|
||||
``get_shape_matrix`` are mocked separately to read off this stand-in."""
|
||||
import numpy as np
|
||||
|
||||
shape = MagicMock(name="shape")
|
||||
shape.geometry = MagicMock(name="geometry")
|
||||
shape._verts = np.asarray(verts_local, dtype=np.float64)
|
||||
shape._matrix = np.asarray(matrix_world_4x4, dtype=np.float64)
|
||||
return shape
|
||||
|
||||
|
||||
def test_opening_axis_extent_uses_geometry_kernel_vertices():
|
||||
"""``_opening_axis_extent`` drives ``ifcopenshell.geom.create_shape`` to
|
||||
get the opening's real geometry vertices and ``get_shape_matrix`` to get
|
||||
its world placement, then projects the world-space corners onto the wall
|
||||
axis. This is the production path — works for every representation type
|
||||
Bonsai may produce (mapped representation, swept area, brep, boolean).
|
||||
|
||||
A unit cube centred at world X=5 on a 10m wall axis projects to
|
||||
t ∈ [0.45, 0.55] (the cube spans 0.5m on each axis around the centre)."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
# Unit cube in local coords, centred at (0,0,0), extent ±0.5.
|
||||
verts_local = [
|
||||
(-0.5, -0.5, -0.5),
|
||||
(0.5, -0.5, -0.5),
|
||||
(0.5, 0.5, -0.5),
|
||||
(-0.5, 0.5, -0.5),
|
||||
(-0.5, -0.5, 0.5),
|
||||
(0.5, -0.5, 0.5),
|
||||
(0.5, 0.5, 0.5),
|
||||
(-0.5, 0.5, 0.5),
|
||||
]
|
||||
matrix_world = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
fake_shape = _fake_shape(verts_local, matrix_world)
|
||||
opening = MagicMock(name="opening")
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
|
||||
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
|
||||
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
|
||||
):
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
# Cube spans world X ∈ [4.5, 5.5] → t ∈ [0.45, 0.55].
|
||||
assert min_t == pytest.approx(0.45)
|
||||
assert max_t == pytest.approx(0.55)
|
||||
|
||||
|
||||
def test_opening_axis_extent_falls_back_to_placement_when_geometry_kernel_fails():
|
||||
"""When ``ifcopenshell.geom.create_shape`` raises (representation it
|
||||
can't process), the helper falls back to a degenerate single-point range
|
||||
at the opening's composed placement origin. This is the safety net — it
|
||||
matches the pre-fix center-only semantics rather than dropping the
|
||||
opening entirely."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
opening = MagicMock(name="opening")
|
||||
placement_matrix = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", side_effect=RuntimeError("kernel failure")),
|
||||
patch("ifcopenshell.util.placement.get_local_placement") as mock_get_placement,
|
||||
):
|
||||
mock_get_placement.return_value = type("FakeArr", (), {"tolist": lambda self: placement_matrix})()
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
assert min_t == max_t == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_opening_axis_extent_offset_cursor_inside_extent_returns_straddling_range():
|
||||
"""The regression guard for the user-reported bug across two fix attempts:
|
||||
when the cursor is placed *inside* the opening but not at its exact
|
||||
centre, the helper must still return a range that straddles the cursor
|
||||
position so the side test keeps the opening on both walls.
|
||||
|
||||
Pre-fix v2/v3 collapsed to a degenerate range whenever the production
|
||||
representation type wasn't recognised (Blender bound_box absent in v2;
|
||||
mapped representation not walked in v3). The current implementation uses
|
||||
``ifcopenshell.geom.create_shape``, which handles every representation
|
||||
Bonsai may produce."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
# 2m-wide opening centred at world X=5 → world X ∈ [4.0, 6.0] → t ∈ [0.4, 0.6].
|
||||
verts_local = [(-1.0, -0.5, -0.5), (1.0, 0.5, 0.5)]
|
||||
matrix_world = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
fake_shape = _fake_shape(verts_local, matrix_world)
|
||||
opening = MagicMock(name="opening")
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
|
||||
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
|
||||
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
|
||||
):
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
# Cursor at world X=4.7 → t=0.47 (inside the opening, not centred on it).
|
||||
cut_percentage = 0.47
|
||||
assert (
|
||||
min_t < cut_percentage < max_t
|
||||
), f"opening [t={min_t}, t={max_t}] must straddle off-centre cursor at t={cut_percentage}"
|
||||
|
||||
|
||||
def test_straddling_opening_is_kept_on_both_sides():
|
||||
"""The pruning logic must keep an opening whose extent straddles the cut
|
||||
on *both* element1 and element2.
|
||||
|
||||
Before the fix, an opening with centre at t=0.5 and cut_percentage=0.6
|
||||
would be removed from element2 (centre < cut) but kept on element1; the
|
||||
opening's right half — which physically overlaps element2 — would be
|
||||
silently dropped. After the fix, the opening overlaps both portions of
|
||||
the axis (min_t=0.3 < 0.6 < max_t=0.7) so both walls keep it.
|
||||
|
||||
Replays the boolean comparisons that ``DumbWallJoiner.split`` performs on
|
||||
the helper's return value; does not call the helper itself."""
|
||||
min_t, max_t = 0.3, 0.7 # straddles any cut_percentage in (0.3, 0.7)
|
||||
cut_percentage = 0.6
|
||||
|
||||
removed_from_element1 = min_t > cut_percentage
|
||||
removed_from_element2 = max_t < cut_percentage
|
||||
|
||||
assert removed_from_element1 is False, "straddling opening must remain on element1"
|
||||
assert removed_from_element2 is False, "straddling opening must remain on element2"
|
||||
|
||||
|
||||
def test_opening_entirely_past_cut_is_removed_from_element1_only():
|
||||
"""Opening lies wholly on element2's side (min_t > cut_percentage).
|
||||
Pre-fix and post-fix both remove it from element1; post-fix additionally
|
||||
guarantees it stays on element2 because max_t > cut_percentage."""
|
||||
min_t, max_t = 0.7, 0.9
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is True # removed from element1
|
||||
assert (max_t < cut_percentage) is False # kept on element2
|
||||
|
||||
|
||||
def test_opening_entirely_before_cut_is_removed_from_element2_only():
|
||||
"""Mirror of the above: opening wholly on element1's side."""
|
||||
min_t, max_t = 0.1, 0.3
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is False # kept on element1
|
||||
assert (max_t < cut_percentage) is True # removed from element2
|
||||
|
||||
|
||||
def test_opening_touching_cut_at_boundary_stays_on_both_walls():
|
||||
"""Boundary touch: an opening's ``max_t`` lands exactly on the cut. Strict
|
||||
inequalities keep the opening on both walls — the safer default. (Non-
|
||||
strict ``<=`` would have removed from element2 instead.)"""
|
||||
min_t, max_t = 0.2, 0.5
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is False # kept on element1
|
||||
assert (max_t < cut_percentage) is False # kept on element2 (boundary == cut)
|
||||
|
||||
|
||||
def test_degenerate_range_at_cut_keeps_opening_on_both_walls():
|
||||
"""Regression guard for the **post-fix-v1 regression**: when the helper
|
||||
falls back to a degenerate range ``(t, t)`` (geometry kernel failed, or
|
||||
the pre-create_shape fix attempts that produced only the placement
|
||||
centre), placing the 3D cursor *on* the opening's centre makes
|
||||
``cut_percentage == t``.
|
||||
|
||||
With non-strict ``>=`` / ``<=`` tests, the degenerate range matched both
|
||||
removal conditions and both walls dropped the opening — leaving the user
|
||||
with two walls and no hole anywhere. Strict ``>`` / ``<`` tests keep the
|
||||
opening on both walls in this case, which matches the visible geometry."""
|
||||
min_t, max_t = 0.5, 0.5 # degenerate range — both bounds at the centre
|
||||
cut_percentage = 0.5 # cursor placed exactly on the opening centre
|
||||
|
||||
removed_from_element1 = min_t > cut_percentage
|
||||
removed_from_element2 = max_t < cut_percentage
|
||||
|
||||
assert removed_from_element1 is False, "must not remove from element1 when cursor sits on opening centre"
|
||||
assert removed_from_element2 is False, "must not remove from element2 when cursor sits on opening centre"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filled-opening void-straddle behaviour
|
||||
#
|
||||
# When a wall split passes through a door/window, the filling (the door
|
||||
# element itself) belongs to whichever wall contains its centre — but the
|
||||
# void cut by the IfcOpeningElement may still straddle the cut, in which
|
||||
# case the neighbour wall's body must also be cut. The helper that adds the
|
||||
# pure-void copy is ``_add_void_copy``; the decision is taken in
|
||||
# ``DumbWallJoiner.split``'s filled-opening loop.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_void_copy_mock(has_filling_rel=True):
|
||||
"""Build the ``void_copy`` MagicMock returned by ``copy_class`` so its
|
||||
``HasFillings`` / ``VoidsElements`` / ``ObjectPlacement`` shape matches
|
||||
what ``_add_void_copy`` mutates."""
|
||||
copy_placement = MagicMock(name="copy_placement")
|
||||
copy_placement.is_a = lambda klass: klass == "IfcLocalPlacement"
|
||||
void_relation = MagicMock(name="VoidsRelation")
|
||||
void_copy = MagicMock(name="void_copy")
|
||||
void_copy.HasFillings = (MagicMock(name="copy_filling_rel"),) if has_filling_rel else ()
|
||||
void_copy.VoidsElements = (void_relation,)
|
||||
void_copy.ObjectPlacement = copy_placement
|
||||
return void_copy, void_relation, copy_placement
|
||||
|
||||
|
||||
def test_add_void_copy_strips_fillings_and_reparents_to_target_wall():
|
||||
"""``_add_void_copy`` must create a pure-void IfcOpeningElement attached
|
||||
to the target wall: the filling relationship copied along with the source
|
||||
must be removed, ``VoidsElements[0].RelatingBuildingElement`` must point
|
||||
at the target wall, and the representation must be a deep copy (not a
|
||||
shared reference with the source)."""
|
||||
from bonsai.bim.module.model.wall import _add_void_copy
|
||||
|
||||
source_representation = MagicMock(name="source_representation")
|
||||
source_opening = MagicMock(name="source_opening")
|
||||
source_opening.Representation = source_representation
|
||||
|
||||
void_copy, void_relation, copy_placement = _make_void_copy_mock()
|
||||
carried_filling_rel = void_copy.HasFillings[0]
|
||||
|
||||
target_placement = MagicMock(name="target_placement")
|
||||
target_wall = MagicMock(name="target_wall")
|
||||
target_wall.ObjectPlacement = target_placement
|
||||
|
||||
ifc_file = MagicMock(name="ifc_file")
|
||||
deep_copy_result = MagicMock(name="copied_representation")
|
||||
|
||||
with (
|
||||
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
|
||||
patch("ifcopenshell.api.root.copy_class", return_value=void_copy) as mock_copy_class,
|
||||
patch("ifcopenshell.util.element.copy_deep", return_value=deep_copy_result),
|
||||
):
|
||||
_add_void_copy(target_wall, source_opening)
|
||||
|
||||
# The carried-over filling relationship must be removed — the copy is a pure void.
|
||||
ifc_file.remove.assert_called_once_with(carried_filling_rel)
|
||||
# The void now points at the target wall, not the source's wall.
|
||||
assert void_relation.RelatingBuildingElement is target_wall
|
||||
# The placement is reparented under the target wall's local placement.
|
||||
assert copy_placement.PlacementRelTo is target_placement
|
||||
# The representation is deep-copied so future edits don't ripple back to source.
|
||||
assert void_copy.Representation is deep_copy_result
|
||||
mock_copy_class.assert_called_once_with(ifc_file, product=source_opening)
|
||||
|
||||
|
||||
def test_add_void_copy_handles_source_with_no_fillings():
|
||||
"""If the source opening has no ``HasFillings`` (the copy_class result
|
||||
inherits that), the loop over ``void_copy.HasFillings or ()`` must run
|
||||
zero times — no spurious ``ifc_file.remove`` call."""
|
||||
from bonsai.bim.module.model.wall import _add_void_copy
|
||||
|
||||
source_opening = MagicMock(name="source_opening")
|
||||
source_opening.Representation = MagicMock(name="rep")
|
||||
|
||||
void_copy, _void_relation, _copy_placement = _make_void_copy_mock(has_filling_rel=False)
|
||||
target_wall = MagicMock(name="target_wall")
|
||||
|
||||
ifc_file = MagicMock(name="ifc_file")
|
||||
|
||||
with (
|
||||
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
|
||||
patch("ifcopenshell.api.root.copy_class", return_value=void_copy),
|
||||
patch("ifcopenshell.util.element.copy_deep", return_value=MagicMock()),
|
||||
):
|
||||
_add_void_copy(target_wall, source_opening)
|
||||
|
||||
ifc_file.remove.assert_not_called()
|
||||
|
||||
|
||||
def test_filled_opening_void_straddle_decision_keeps_void_on_neighbour():
|
||||
"""Replays the decision logic in ``DumbWallJoiner.split``'s filled-opening
|
||||
loop for the case ``filling_position <= cut_percentage and void_straddles``:
|
||||
filling stays on element1 (its centre is before the cut), but the void
|
||||
extent crosses the cut, so the neighbour wall (element2) must receive a
|
||||
pure-void copy via ``_add_void_copy``.
|
||||
|
||||
Mirrors the unfilled-opening decision tests — exercises the boolean
|
||||
branching rather than full ``split()`` integration."""
|
||||
cut_percentage = 0.5
|
||||
filling_position = 0.4 # filling centre on element1's side
|
||||
min_t, max_t = 0.3, 0.7 # void extent straddles cut at 0.5
|
||||
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
filling_on_element2 = filling_position > cut_percentage
|
||||
|
||||
# Expected branch: filling stays, but void straddles → add copy to element2.
|
||||
assert void_straddles is True
|
||||
assert filling_on_element2 is False
|
||||
# Equivalent to the ``elif void_straddles:`` path adding a void copy to element2.
|
||||
|
||||
|
||||
def test_filled_opening_void_straddle_with_filling_on_far_side_keeps_void_on_origin():
|
||||
"""The symmetric case: ``filling_position > cut_percentage and void_straddles``.
|
||||
Filling moves to element2 with the original void; element1 needs a
|
||||
pure-void copy back (the void's element1 portion would otherwise be
|
||||
orphaned). Documents the boolean state of the inner branch."""
|
||||
cut_percentage = 0.5
|
||||
filling_position = 0.6 # filling centre on element2's side
|
||||
min_t, max_t = 0.3, 0.7
|
||||
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
filling_on_element2 = filling_position > cut_percentage
|
||||
|
||||
assert void_straddles is True
|
||||
assert filling_on_element2 is True
|
||||
# Equivalent to the outer ``if filling_position > cut_percentage`` path
|
||||
# taking its inner ``if void_straddles`` branch and adding a void copy
|
||||
# back to element1.
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1131,6 +1133,17 @@ def the_variable_key_is_value(key, value):
|
||||
variables[key] = eval(replace_variables(value))
|
||||
|
||||
|
||||
@then(parsers.parse('the variable "{key}" equals "{value}"'))
|
||||
def the_variable_key_equals_value(key, value):
|
||||
assert key in variables, f'Variable "{key}" was never set'
|
||||
expected = eval(replace_variables(value))
|
||||
actual = variables[key]
|
||||
if isinstance(actual, float) and isinstance(expected, float):
|
||||
assert abs(actual - expected) < 1e-5, f'Variable "{key}" is {actual!r}, expected {expected!r}'
|
||||
else:
|
||||
assert actual == expected, f'Variable "{key}" is {actual!r}, expected {expected!r}'
|
||||
|
||||
|
||||
@then("nothing happens")
|
||||
def nothing_happens():
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit coverage for the shared parametric-edit lifecycle mixins.
|
||||
|
||||
``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6
|
||||
parametric features (door, window, railing, roof). The registry smoke test
|
||||
elsewhere verifies operators are wired up; the mixins' own state-transition
|
||||
contracts are tested here.
|
||||
|
||||
The mixins are exercised through minimal in-test subclasses that supply the
|
||||
abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and
|
||||
``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are
|
||||
patched at the module attribute (not the source module) so each test sees
|
||||
isolated mock state."""
|
||||
|
||||
import json
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
import types as _types
|
||||
|
||||
import bpy
|
||||
|
||||
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
class _FakeProps:
|
||||
"""Stand-in for ``BIM<Name>Properties`` — records what was set so tests can
|
||||
assert state transitions without instantiating real PropertyGroups."""
|
||||
|
||||
def __init__(self):
|
||||
self.is_editing = False
|
||||
self.last_kwargs = None
|
||||
self.general = {"width": 1000}
|
||||
self.lining = {"thickness": 50}
|
||||
self.panel = {"material": "wood"}
|
||||
|
||||
def set_props_kwargs_from_ifc_data(self, data):
|
||||
self.last_kwargs = dict(data)
|
||||
|
||||
def get_general_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.general)
|
||||
|
||||
def get_lining_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.lining)
|
||||
|
||||
def get_panel_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.panel)
|
||||
|
||||
|
||||
def _make_obj(props):
|
||||
obj = mock.Mock()
|
||||
obj.props = props
|
||||
obj.name = "TestObj"
|
||||
return obj
|
||||
|
||||
|
||||
def _make_pset_text(general, lining, panel):
|
||||
payload = {"lining_properties": lining, "panel_properties": panel, **general}
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# FeatureModifierEditMixin (door/window pattern)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _door_mixin_cls(match=True, raise_on_update=False):
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
raised = raise_on_update
|
||||
|
||||
class _TestDoorMixin(FeatureModifierEditMixin):
|
||||
pset_name: ClassVar[str] = "BBIM_Door"
|
||||
representations_called: ClassVar[list] = []
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return match
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj):
|
||||
return obj.props
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj, context):
|
||||
cls.representations_called.append(obj)
|
||||
if raised:
|
||||
raise RuntimeError("simulated representation failure")
|
||||
|
||||
return _TestDoorMixin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_tool_and_ifc():
|
||||
"""Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module.
|
||||
|
||||
Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset,
|
||||
mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return
|
||||
values and assert call args."""
|
||||
target = "bonsai.bim.parametric_lifecycle"
|
||||
with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch(
|
||||
f"{target}.bonsai"
|
||||
) as mock_bonsai:
|
||||
# Element returned by tool.Ifc.get_entity is reused across mocks.
|
||||
element = mock.Mock(name="entity")
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file")
|
||||
mock_tool.Model.get_constituents_props_data.return_value = {"materials": []}
|
||||
mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset")
|
||||
mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark
|
||||
yield {
|
||||
"tool": mock_tool,
|
||||
"ifc": mock_ifc,
|
||||
"bonsai": mock_bonsai,
|
||||
"element": element,
|
||||
}
|
||||
|
||||
|
||||
def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
|
||||
{"width": 1234}, {"thickness": 50}, {"material": "wood"}
|
||||
)
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is True
|
||||
assert props.last_kwargs is not None
|
||||
assert props.last_kwargs["width"] == 1234
|
||||
assert props.last_kwargs["thickness"] == 50
|
||||
assert props.last_kwargs["material"] == "wood"
|
||||
assert "materials" in props.last_kwargs # from get_constituents_props_data
|
||||
|
||||
|
||||
def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
|
||||
cls = _door_mixin_cls(match=False)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert props.last_kwargs is None
|
||||
# get_pset must not be called when _is_element_type returns False — the
|
||||
# _resolve guard short-circuits before reading pset data.
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called()
|
||||
|
||||
|
||||
def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc):
|
||||
"""tool.Ifc.get_entity returning None must short-circuit before predicate runs."""
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is False
|
||||
|
||||
|
||||
def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._finish_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert obj in cls.representations_called
|
||||
# tool.Pset.write_bbim_data is called exactly once with the merged dict.
|
||||
patched_tool_and_ifc["tool"].Pset.write_bbim_data.assert_called_once()
|
||||
call_args = patched_tool_and_ifc["tool"].Pset.write_bbim_data.call_args
|
||||
assert call_args.args[1] == "BBIM_Door" # pset_name positional arg
|
||||
written_data = call_args.args[2]
|
||||
assert "lining_properties" in written_data and "panel_properties" in written_data
|
||||
|
||||
|
||||
def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
|
||||
"""If _update_modifier_representation raises, is_editing must stay True
|
||||
so the user's draft survives for retry. This is the contract called out
|
||||
in parametric_lifecycle.py:161 — set is_editing=False only on success."""
|
||||
props = _FakeProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
|
||||
cls = _door_mixin_cls(match=True, raise_on_update=True)
|
||||
with pytest.raises(RuntimeError, match="simulated representation failure"):
|
||||
cls._finish_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is True # draft survives
|
||||
|
||||
|
||||
def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
|
||||
{"width": 900}, {"thickness": 60}, {"material": "steel"}
|
||||
)
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._cancel_one(obj)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert props.last_kwargs is not None and props.last_kwargs["width"] == 900
|
||||
# switch_representation must be called via bonsai.core.geometry.
|
||||
patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once()
|
||||
|
||||
|
||||
def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc):
|
||||
"""_enable_targets / _finish_targets / _cancel_targets iterate
|
||||
_iter_targets — default is [active_object]; subclasses can override."""
|
||||
props_a, props_b = _FakeProps(), _FakeProps()
|
||||
obj_a, obj_b = _make_obj(props_a), _make_obj(props_b)
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
|
||||
{"width": 1000}, {"thickness": 50}, {"material": "wood"}
|
||||
)
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b])
|
||||
|
||||
result = cls()._enable_targets(mock.Mock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert props_a.is_editing is True
|
||||
assert props_b.is_editing is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# PathPreservingEditMixin (railing/roof pattern)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakePathProps:
|
||||
"""Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel)."""
|
||||
|
||||
def __init__(self):
|
||||
self.is_editing = False
|
||||
self.last_kwargs = None
|
||||
self.general = {"width": 200, "thickness": 10}
|
||||
|
||||
def set_props_kwargs_from_ifc_data(self, data):
|
||||
self.last_kwargs = dict(data)
|
||||
|
||||
def get_general_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.general)
|
||||
|
||||
|
||||
def _path_mixin_cls(match=True):
|
||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
||||
|
||||
class _TestPathMixin(PathPreservingEditMixin):
|
||||
pset_name: ClassVar[str] = "BBIM_Railing"
|
||||
pset_updates: ClassVar[list] = []
|
||||
ifc_data_updates: ClassVar[list] = []
|
||||
bmesh_updates: ClassVar[list] = []
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return match
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj):
|
||||
return obj.props
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data):
|
||||
cls.pset_updates.append((element, data))
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj, context):
|
||||
cls.ifc_data_updates.append(obj)
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj, context):
|
||||
cls.bmesh_updates.append(obj)
|
||||
|
||||
return _TestPathMixin
|
||||
|
||||
|
||||
def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc):
|
||||
props = _FakePathProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is True
|
||||
assert props.last_kwargs is not None
|
||||
assert props.last_kwargs["width"] == 250
|
||||
# path_data passes through (default _post_load_data is pass-through)
|
||||
assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]}
|
||||
|
||||
|
||||
def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc):
|
||||
props = _FakePathProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]}
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"path_data": sentinel_path}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._finish_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert cls.pset_updates, "_update_pset must be called on Finish"
|
||||
assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference
|
||||
assert obj in cls.ifc_data_updates
|
||||
|
||||
|
||||
def test_path_preserving_cancel_one_calls_restore_viewport_after_cancel(patched_tool_and_ifc):
|
||||
props = _FakePathProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"width": 250, "path_data": {"points": []}}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._cancel_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert obj in cls.bmesh_updates
|
||||
|
||||
|
||||
def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc):
|
||||
"""Railing overrides _post_load_data to JSON-serialise path_data —
|
||||
confirm the hook is honoured (here we drop a sentinel key)."""
|
||||
props = _FakePathProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"width": 250, "extra": "drop_me"}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"})
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert "extra" not in props.last_kwargs
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _ParametricEditMixinBase._resolve guard
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc):
|
||||
cls = _door_mixin_cls(match=True)
|
||||
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
|
||||
obj = _make_obj(_FakeProps())
|
||||
|
||||
assert cls._resolve(obj) is None
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc):
|
||||
cls = _door_mixin_cls(match=False)
|
||||
obj = _make_obj(_FakeProps())
|
||||
|
||||
assert cls._resolve(obj) is None
|
||||
|
||||
|
||||
def test_resolve_returns_tuple_when_match(patched_tool_and_ifc):
|
||||
cls = _door_mixin_cls(match=True)
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
|
||||
resolved = cls._resolve(obj)
|
||||
|
||||
assert resolved is not None
|
||||
element, returned_props = resolved
|
||||
assert element is patched_tool_and_ifc["element"]
|
||||
assert returned_props is props
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
|
||||
"""Registration smoke test for `tool.Parametric.EDIT_TYPES`.
|
||||
|
||||
The registry is the single source of truth for which parametric element types
|
||||
exist. Every consumer (auto-commit on save, finish/cancel chains, the
|
||||
``PointerProperty`` attachment, the ``GizmoPreferences<X>`` registration) derives
|
||||
identifiers from each entry's short ``name`` token. Forget any downstream
|
||||
registration and the silent-desync the framework exists to prevent will ship.
|
||||
|
||||
These tests pin the registry-to-runtime contract: for every entry the operator
|
||||
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
|
||||
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
|
||||
predicate exists on `tool.Blender.Modifier`."""
|
||||
|
||||
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)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
from bonsai import tool
|
||||
|
||||
return tool.Parametric.EDIT_TYPES
|
||||
|
||||
|
||||
def test_registry_is_non_empty(registry):
|
||||
assert len(registry) >= 1
|
||||
|
||||
|
||||
def test_every_entry_has_enable_op_registered(registry):
|
||||
missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))]
|
||||
assert not missing, f"Missing enable operators: {missing}"
|
||||
|
||||
|
||||
def test_every_entry_has_finish_op_registered(registry):
|
||||
missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))]
|
||||
assert not missing, f"Missing finish operators: {missing}"
|
||||
|
||||
|
||||
def test_every_entry_has_cancel_op_registered(registry):
|
||||
missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))]
|
||||
assert not missing, f"Missing cancel operators: {missing}"
|
||||
|
||||
|
||||
def test_every_entry_has_property_group_attached(registry):
|
||||
# ``register_object_properties`` runs at addon enable; if any entry's
|
||||
# PropertyGroup class is missing on prop module the attribute is skipped.
|
||||
missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)]
|
||||
assert not missing, (
|
||||
f"bpy.types.Object missing attributes: {missing} — "
|
||||
f"verify the matching PropertyGroup classes exist in bim.module.model.prop"
|
||||
)
|
||||
|
||||
|
||||
def test_every_entry_has_modifier_predicate(registry):
|
||||
from bonsai import tool
|
||||
|
||||
missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
|
||||
assert not missing, f"tool.Blender.Modifier missing is_<name> predicates: {missing}"
|
||||
|
||||
|
||||
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
"""Each ``is_<name>`` predicate must be **total**: accept any IFC entity
|
||||
and return a truthy/falsy value, never raise.
|
||||
|
||||
The registry iterates every predicate against the active IFC element on
|
||||
save; a raising predicate (e.g. ``AttributeError`` from a missing pset
|
||||
accessor when handed a non-matching element type) propagates upward and
|
||||
breaks the save path for *all* parametric types, not just its own.
|
||||
This test probes each predicate with an ``IfcAnnotation`` (an element
|
||||
that carries none of the BBIM_<Type> psets the predicates look up) and
|
||||
asserts the call does not raise. Falsy returns are acceptable — the
|
||||
registry treats them as 'no match'. What's forbidden is raising."""
|
||||
import ifcopenshell
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation")
|
||||
|
||||
raised = []
|
||||
for feature in registry:
|
||||
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
|
||||
if predicate is None:
|
||||
continue
|
||||
try:
|
||||
predicate(probe)
|
||||
except Exception as e:
|
||||
raised.append((feature.name, type(e).__name__, str(e)))
|
||||
assert not raised, (
|
||||
f"is_<name> predicates raised on a non-matching IfcAnnotation: {raised}. "
|
||||
f"Predicates must be total — return bool, never raise. Add an "
|
||||
f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup."
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_preferences_attached_when_class_exists(registry):
|
||||
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
|
||||
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
|
||||
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
|
||||
|
||||
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
|
||||
discovery: a typo in the class name or a dropped registration would
|
||||
otherwise produce a missing sub-panel at runtime with no error.
|
||||
Entries without a ``GizmoPreferences<Name>`` class are allowed — not
|
||||
every parametric type ships gizmo prefs.
|
||||
|
||||
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
|
||||
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
|
||||
annotation-only assignment — the attribute only materialises on the
|
||||
class after Blender's metaclass installs the bpy_struct descriptor,
|
||||
which depends on registration timing. Reading ``__annotations__``
|
||||
pins the source-level contract independently of when register() ran."""
|
||||
from bonsai.bim import ui
|
||||
|
||||
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
|
||||
missing = []
|
||||
for feature in registry:
|
||||
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
|
||||
if not hasattr(ui, prefs_class_name):
|
||||
continue
|
||||
if feature.name not in annotations:
|
||||
missing.append((feature.name, prefs_class_name))
|
||||
assert not missing, (
|
||||
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing} — "
|
||||
f"each registered ``GizmoPreferences<Name>`` class must have a matching "
|
||||
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
|
||||
f"``ui.GizmoPreferences``"
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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 pure-Python math helpers in bonsai.core.model used by the wall gizmo system.
|
||||
|
||||
These run in the core lane (``pytest test/core/``) — no Blender, no IFC file. The
|
||||
helpers under test live in ``bonsai/core/model.py`` and are deliberately pure (tuple
|
||||
in, tuple out) so they're exercisable without ``mathutils`` or ``bpy``."""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.core.model as subject
|
||||
|
||||
|
||||
class TestBaselineFromOffset:
|
||||
THICKNESS = 0.2
|
||||
|
||||
def test_positive_direction_exterior(self):
|
||||
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
|
||||
|
||||
def test_positive_direction_center(self):
|
||||
assert subject.baseline_from_offset(-self.THICKNESS / 2, self.THICKNESS) == "CENTER"
|
||||
|
||||
def test_positive_direction_interior(self):
|
||||
assert subject.baseline_from_offset(-self.THICKNESS, self.THICKNESS) == "INTERIOR"
|
||||
|
||||
def test_negative_direction_exterior(self):
|
||||
assert subject.baseline_from_offset(self.THICKNESS, self.THICKNESS) == "EXTERIOR"
|
||||
|
||||
def test_negative_direction_center(self):
|
||||
assert subject.baseline_from_offset(self.THICKNESS / 2, self.THICKNESS) == "CENTER"
|
||||
|
||||
def test_negative_direction_interior(self):
|
||||
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
|
||||
|
||||
def test_within_tolerance_still_matches(self):
|
||||
# A 0.5mm jitter on a 200mm wall should still classify cleanly.
|
||||
assert subject.baseline_from_offset(-self.THICKNESS / 2 + 0.0005, self.THICKNESS) == "CENTER"
|
||||
|
||||
def test_outside_tolerance_falls_back_to_center(self):
|
||||
# 50mm offset on a 200mm wall — not a canonical position.
|
||||
assert subject.baseline_from_offset(0.05, self.THICKNESS) == "CENTER"
|
||||
|
||||
|
||||
class TestProjectAxisIntersection:
|
||||
PARALLEL_THRESHOLD = 0.9994 # cos(2°)
|
||||
|
||||
def test_perpendicular_walls_meet_at_corner(self):
|
||||
# Wall A along +X from origin; wall B along +Y from (5, 0, 0).
|
||||
# Axes meet exactly at (5, 0).
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
|
||||
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
|
||||
assert result is not None
|
||||
assert result[0] == pytest.approx(5.0)
|
||||
assert result[1] == pytest.approx(0.0)
|
||||
|
||||
def test_offset_walls_intersect_at_extrapolated_point(self):
|
||||
# Wall A: y=0 from x=1 to x=6.
|
||||
# Wall B: x=0 from y=1 to y=4.
|
||||
# Infinite-line intersection at (0, 0).
|
||||
seg_a = ((1.0, 0.0, 0.0), (6.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (0.0, 4.0, 0.0))
|
||||
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
|
||||
assert result is not None
|
||||
assert result[0] == pytest.approx(0.0)
|
||||
assert result[1] == pytest.approx(0.0)
|
||||
|
||||
def test_parallel_walls_return_none(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_anti_parallel_walls_return_none(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 1.0, 0.0), (0.0, 1.0, 0.0)) # opposite direction
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_nearly_parallel_walls_return_none(self):
|
||||
# 1° off parallel — within the ~2° dead-band.
|
||||
angle = math.radians(1)
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (5.0 * math.cos(angle), 1.0 + 5.0 * math.sin(angle), 0.0))
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_zero_length_segment_returns_none(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0))
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_intersection_z_is_average_of_endpoint_zs(self):
|
||||
# Walls at different elevations; the icon-placement Z should be the average.
|
||||
seg_a = ((0.0, 0.0, 1.0), (5.0, 0.0, 1.0)) # at z=1
|
||||
seg_b = ((5.0, 0.0, 3.0), (5.0, 3.0, 3.0)) # at z=3
|
||||
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
|
||||
assert result is not None
|
||||
assert result[2] == pytest.approx(2.0)
|
||||
|
||||
|
||||
class TestSlopeRoundTrip:
|
||||
def test_zero_angle_zero_displacement(self):
|
||||
assert subject.displacement_from_x_angle(3.0, 0.0) == pytest.approx(0.0)
|
||||
assert subject.x_angle_from_displacement(3.0, 0.0) == pytest.approx(0.0)
|
||||
|
||||
def test_positive_angle_positive_displacement(self):
|
||||
# 30° slope on a 3m wall → top moves ~1.732m in +Y.
|
||||
displacement = subject.displacement_from_x_angle(3.0, math.radians(30))
|
||||
assert displacement == pytest.approx(3.0 * math.tan(math.radians(30)))
|
||||
|
||||
def test_negative_angle_negative_displacement(self):
|
||||
displacement = subject.displacement_from_x_angle(3.0, math.radians(-15))
|
||||
assert displacement < 0
|
||||
|
||||
def test_round_trip_preserves_angle(self):
|
||||
# Drag-to-angle-to-drag preserves the original.
|
||||
original_angle = math.radians(20)
|
||||
displacement = subject.displacement_from_x_angle(3.0, original_angle)
|
||||
recovered = subject.x_angle_from_displacement(3.0, displacement)
|
||||
assert recovered == pytest.approx(original_angle, abs=1e-9)
|
||||
|
||||
def test_round_trip_handles_zero_height(self):
|
||||
# Walls of effectively zero height should not divide-by-zero.
|
||||
recovered = subject.x_angle_from_displacement(0.0, 1.0)
|
||||
assert recovered == pytest.approx(math.pi / 2, abs=1e-3)
|
||||
|
||||
|
||||
class TestAreAxesCollinear:
|
||||
PARALLEL_THRESHOLD = 0.9994
|
||||
LINE_TOLERANCE = 0.05
|
||||
|
||||
def test_end_to_end_walls_along_x_are_collinear(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_separated_collinear_walls_with_gap(self):
|
||||
# Walls with a 1m gap between them — still on the same line.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((6.0, 0.0, 0.0), (10.0, 0.0, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_perpendicular_walls_are_not_collinear(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 0.0, 0.0), (0.0, 5.0, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_parallel_walls_offset_perpendicular_are_not_collinear(self):
|
||||
# Two parallel walls 1m apart — same direction but not the same line.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_anti_parallel_collinear_walls(self):
|
||||
# Reversed direction on the same line still counts as collinear.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((10.0, 0.0, 0.0), (6.0, 0.0, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_z_is_ignored_for_plan_collinearity(self):
|
||||
# Walls on different floors are still considered collinear in plan.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_zero_length_segment_is_not_collinear(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_slightly_off_line_within_tolerance(self):
|
||||
# 2cm perpendicular offset — still within the 5cm tolerance.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.02, 0.0), (10.0, 0.02, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_too_far_off_line_fails_tolerance(self):
|
||||
# 10cm perpendicular offset — outside the 5cm tolerance.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.10, 0.0), (10.0, 0.10, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
|
||||
class TestClosestEndpointMidpoint:
|
||||
def test_end_to_end_walls_midpoint_is_the_shared_corner(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
|
||||
|
||||
def test_walls_with_gap_midpoint_is_in_the_gap(self):
|
||||
# Wall A ends at x=5; wall B starts at x=7. Boundary midpoint is at x=6.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((7.0, 0.0, 0.0), (12.0, 0.0, 0.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
assert result == (pytest.approx(6.0), pytest.approx(0.0), pytest.approx(0.0))
|
||||
|
||||
def test_perpendicular_walls_midpoint_is_between_nearest_endpoints(self):
|
||||
# Wall A's +X endpoint (5,0,0) and wall B's origin (5,0,0) → midpoint at (5,0,0).
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
|
||||
|
||||
def test_z_averaged_when_walls_at_different_elevations(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
# Closest pair: (5,0,0) and (5,0,3); midpoint Z = 1.5.
|
||||
assert result[2] == pytest.approx(1.5)
|
||||
|
||||
|
||||
class TestVerticalHeightFromExtrusionDepth:
|
||||
def test_vertical_wall_returns_depth_unchanged(self):
|
||||
assert subject.vertical_height_from_extrusion_depth(3.0, 0.0) == pytest.approx(3.0)
|
||||
|
||||
def test_30_degree_slope(self):
|
||||
# cos(30°) ≈ 0.866 → vertical height of a 3m slanted extrusion ≈ 2.598m.
|
||||
result = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
|
||||
assert result == pytest.approx(3.0 * math.cos(math.radians(30)))
|
||||
|
||||
def test_negative_angle_yields_same_magnitude(self):
|
||||
positive = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
|
||||
negative = subject.vertical_height_from_extrusion_depth(3.0, math.radians(-30))
|
||||
assert positive == pytest.approx(negative)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,380 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Bruno Perdigão <contact@brunopo.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 inspect
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model.data import AuthoringData as Model
|
||||
|
||||
GREEN = "\033[32m"
|
||||
RED = "\033[31m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def _assert_pass(message: str) -> None:
|
||||
caller_name = inspect.stack()[1].function
|
||||
print(f"{GREEN}{caller_name} PASSED: {message}{RESET}")
|
||||
|
||||
|
||||
def _handle_error(e: Exception, on_done) -> None:
|
||||
print(f"{RED}Assertion failed: {e}{RESET}")
|
||||
if on_done:
|
||||
on_done()
|
||||
|
||||
|
||||
def run_iter_from_timer(event_iter, on_complete=None, on_error=None):
|
||||
i = iter(event_iter)
|
||||
done = False
|
||||
|
||||
def event_step():
|
||||
nonlocal done, on_complete
|
||||
try:
|
||||
ret = next(i, "STOP")
|
||||
if ret in (None, "STOP", "FINISHED"):
|
||||
done = True
|
||||
if on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
except StopIteration:
|
||||
done = True
|
||||
if on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
except Exception as e:
|
||||
done = True
|
||||
print(f"Exception: {e}")
|
||||
if on_error:
|
||||
on_error(e)
|
||||
elif on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
return 0.0
|
||||
|
||||
bpy.app.timers.register(event_step, first_interval=0.0)
|
||||
|
||||
|
||||
def preset_event_simulate(window, event_type, value, x, y):
|
||||
if value == "TAP":
|
||||
yield window.event_simulate(event_type, "PRESS", x=x, y=y)
|
||||
yield window.event_simulate(event_type, "RELEASE", x=x, y=y)
|
||||
else:
|
||||
yield window.event_simulate(event_type, value, x=x, y=y)
|
||||
|
||||
|
||||
def cleanup():
|
||||
bpy.app.use_event_simulate = False
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
|
||||
def _get_valid_window() -> bpy.types.Window:
|
||||
win = bpy.context.window
|
||||
if win is not None:
|
||||
return win
|
||||
wm = getattr(bpy.context, "window_manager", None)
|
||||
if wm and wm.windows:
|
||||
return wm.windows[0]
|
||||
raise RuntimeError("Unable to locate a Blender UI window.")
|
||||
|
||||
|
||||
def new_project():
|
||||
IfcStore.purge()
|
||||
bpy.ops.wm.read_homefile(app_template="", use_factory_startup=True)
|
||||
if len(bpy.data.objects) > 0:
|
||||
bpy.data.batch_remove(bpy.data.objects)
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
if len(bpy.data.materials) > 0:
|
||||
bpy.data.batch_remove(bpy.data.materials)
|
||||
bpy.context.scene.unit_settings.system = "METRIC"
|
||||
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
|
||||
props = tool.Project.get_project_props()
|
||||
props.template_file = "0"
|
||||
tool.Blender.get_addon_preferences().should_play_chaching_sound = False
|
||||
|
||||
def get_area_and_region(window):
|
||||
area = next(area for area in window.screen.areas if area.type == "VIEW_3D")
|
||||
region = next(region for region in area.regions if region.type == "WINDOW")
|
||||
return area, region
|
||||
|
||||
def test_snap_object_detection(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.5 + area.x)
|
||||
y = round(area.height * 0.54 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcWall"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcWall", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "Second click should not have a snap_object"
|
||||
assert not snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_snap_partially_behind_camera(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.20 + area.x)
|
||||
y = round(area.height * 0.15 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Edge'"
|
||||
assert snap_point.snap_type == "Edge", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcSlab"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "Second click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Face'"
|
||||
assert snap_point.snap_type == "Face", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcSlab"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_snap_in_xray_mode(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.68+ area.x)
|
||||
y = round(area.height * 0.54 + area.y)
|
||||
|
||||
area.spaces[0].shading.show_xray = True
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcFurniture"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcFurniture", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_snap_far_from_origin(window):
|
||||
bpy.context.view_layer.objects.active = None
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.155 + area.x)
|
||||
y = round(area.height * 0.18 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
bpy.data.objects['IfcBuildingElementProxy/Cube'].select_set(True)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.view3d.view_selected()
|
||||
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Vertex'"
|
||||
assert snap_point.snap_type == "Vertex", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "x should be 1000000"
|
||||
assert round(snap_point.x, 3) == 1000.0, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "y should be 1000000"
|
||||
assert round(snap_point.y, 3) == 1000.0, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_draw_polyline_wall(window, x, y):
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
area, region = get_area_and_region(window)
|
||||
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
props = tool.Model.get_model_props()
|
||||
ifc = tool.Ifc.get()
|
||||
relating_type = ifc.by_type("IfcWallType")[0]
|
||||
|
||||
if tool.Model.get_usage_type(relating_type) == "LAYER2":
|
||||
props.ifc_class = "IfcWallType"
|
||||
props.relating_type_id = str(relating_type.id())
|
||||
|
||||
bpy.ops.bim.draw_polyline_wall("INVOKE_DEFAULT")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
yield from preset_event_simulate(window, "X", "TAP", x, y)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x + offset, y)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
element = tool.Ifc.get_entity(bpy.context.selected_objects[0])
|
||||
|
||||
assert_msg = "Created object should be IfcWall"
|
||||
assert element.is_a() == "IfcWall"
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Created object should be typed by IfcWallType"
|
||||
assert ifcopenshell.util.element.get_type(element).is_a() == "IfcWallType"
|
||||
_assert_pass(assert_msg)
|
||||
# TODO Asset the axis has the same X value
|
||||
|
||||
yield "FINISHED"
|
||||
|
||||
|
||||
def run_tests():
|
||||
module_name = os.getenv("MODULE", "snap")
|
||||
if module_name == "wall":
|
||||
filepath = f"./test/files/wall.ifc"
|
||||
bpy.ops.bim.load_project(filepath=filepath)
|
||||
window = _get_valid_window()
|
||||
test_queue = [lambda w=window: test_draw_polyline_wall(w, 960, 540)]
|
||||
elif module_name == "snap":
|
||||
filepath = f"./test/files/snap.ifc"
|
||||
bpy.ops.bim.load_project(filepath=filepath)
|
||||
window = _get_valid_window()
|
||||
test_queue = [
|
||||
lambda w=window: test_snap_object_detection(w),
|
||||
lambda w=window: test_snap_partially_behind_camera(w),
|
||||
lambda w=window: test_snap_in_xray_mode(w),
|
||||
lambda w=window: test_snap_far_from_origin(w),
|
||||
]
|
||||
else:
|
||||
cleanup()
|
||||
|
||||
def _next():
|
||||
if not test_queue:
|
||||
cleanup()
|
||||
return
|
||||
test_fn = test_queue.pop(0)
|
||||
# use the shared timer infrastructure
|
||||
run_iter_from_timer(
|
||||
test_fn(),
|
||||
on_complete=_next,
|
||||
on_error=lambda e: _handle_error(e, _next),
|
||||
)
|
||||
|
||||
_next()
|
||||
|
||||
if __name__ == "__main__":
|
||||
new_project()
|
||||
run_tests()
|
||||
@@ -17,20 +17,20 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import test.bim.bootstrap
|
||||
import ifcopenshell.api.cost
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
import test.bim.bootstrap
|
||||
from bonsai.tool.cost import Cost as subject
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
from bonsai.tool.cost import Cost as subject
|
||||
|
||||
class TestImplementsTool(NewFile):
|
||||
def test_run(self):
|
||||
assert isinstance(subject(), bonsai.core.tool.Cost)
|
||||
|
||||
|
||||
class TestDisableEditingCostItemParent(NewFile):
|
||||
def test_avoid_recursion_error(newfile, monkeypatch):
|
||||
class DummyProps:
|
||||
@@ -39,11 +39,7 @@ class TestDisableEditingCostItemParent(NewFile):
|
||||
self.active_cost_item_id = 5
|
||||
|
||||
props = DummyProps()
|
||||
monkeypatch.setattr(
|
||||
"bonsai.tool.Cost.get_cost_props",
|
||||
lambda: props
|
||||
)
|
||||
monkeypatch.setattr("bonsai.tool.Cost.get_cost_props", lambda: props)
|
||||
subject.disable_editing_cost_item_parent()
|
||||
assert props.active_cost_item_id == 0
|
||||
assert props.change_cost_item_parent is not False
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
|
||||
|
||||
#let bill_of_quantities_table = table(
|
||||
#let bill_of_quantities_table(currency: "") = table(
|
||||
columns: (18mm,54mm, 12mm,12mm,12mm,12mm, 20mm, 20mm, 25mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center, center, center, center, center, center, center),
|
||||
@@ -36,12 +36,12 @@
|
||||
top: 1pt,
|
||||
bottom: 1pt
|
||||
),
|
||||
[Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate], [Total]
|
||||
[Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate (#currency)], [Total (#currency)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
#let schedule_of_rates_table = table(
|
||||
#let schedule_of_rates_table(currency: "") = table(
|
||||
columns: (30mm,130mm, 25mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center),
|
||||
@@ -51,12 +51,12 @@
|
||||
top: 1pt,
|
||||
bottom: 1pt
|
||||
),
|
||||
[Identification], [Description], [Rate]
|
||||
[Identification], [Description], [Rate (#currency)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
#let summary_table = table(
|
||||
#let summary_table(currency: "") = table(
|
||||
columns: (18mm,107mm, 30mm, 30mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center, center, center, center, center, center, center),
|
||||
@@ -67,9 +67,9 @@
|
||||
bottom: 1pt
|
||||
),
|
||||
text(size: 8pt)[Hierarchy],
|
||||
text(size: 8pt)[Description],
|
||||
text(size: 8pt)[Sub Total],
|
||||
text(size: 8pt)[Total]
|
||||
text(size: 8pt)[Description],
|
||||
text(size: 8pt)[Sub Total (#currency)],
|
||||
text(size: 8pt)[Total (#currency)]
|
||||
)
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
#let arrange_summary_row(row, options) = {
|
||||
let name = strong(upper(row.at("Name")))
|
||||
let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))]
|
||||
let total = if row.at("RateSubtotal") == "" {0.0} else {float(row.at("RateSubtotal"))}
|
||||
if row.at("ItemIsASum") == "True" {
|
||||
if row.at("Index") == "1" {
|
||||
// ROOT COST
|
||||
@@ -216,7 +215,8 @@
|
||||
format-decimal(float(row.at("Quantity")))}
|
||||
let rate = if row.at("RateSubtotal") == "" {0.0} else {
|
||||
format-decimal(float(row.at("RateSubtotal")))}
|
||||
let total = if row.at("Quantity") == "" {0.0} else {
|
||||
let total = if row.at("Quantity") == "" or row.at("RateSubtotal") == "" {
|
||||
format-decimal(0.0, places: 2)} else {
|
||||
format-decimal(float(row.at("Quantity")) * float(row.at("RateSubtotal")), places: 2)}
|
||||
|
||||
(
|
||||
@@ -281,18 +281,18 @@
|
||||
#let arrange_schedule_of_rates_row(row, options) = {
|
||||
let name = strong(upper(row.at("Name")))
|
||||
let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))]
|
||||
let unit = table.cell(align: right)[#unit_map.at(row.at("Unit"), default: "")]
|
||||
let unit = table.cell(align: right + bottom)[#unit_map.at(row.at("Unit"), default: "")]
|
||||
let rate = if row.at("RateSubtotal") == "" {0.0} else {
|
||||
format-decimal(float(row.at("RateSubtotal")))}
|
||||
if row.at("ItemIsASum") == "True" {return ()} //skip sections in schedule of rates
|
||||
(
|
||||
row.at("Identification"),
|
||||
if row.at("Identification") == "" {name + linebreak() + description} else {name + linebreak() + description},
|
||||
name + linebreak() + description,
|
||||
[]
|
||||
)
|
||||
(
|
||||
[],
|
||||
table.cell(align: right+bottom)[#unit],
|
||||
unit,
|
||||
table.cell(align: right+bottom)[#rate],
|
||||
)
|
||||
(
|
||||
@@ -342,8 +342,12 @@
|
||||
) = {
|
||||
let data = csv(path, delimiter: delimiter, row-type: dictionary)
|
||||
let new_rows = data.map(item => arrange_summary_row(item, options))
|
||||
let general_total = data.filter(row => row.at("ItemIsASum") == "False")
|
||||
.map(row => float(row.at("RateSubtotal", default: 0.0))*float(row.at("Quantity", default: 0.0)))
|
||||
let general_total = data.filter(row => row.at("ItemIsASum") == "False")
|
||||
.map(row => {
|
||||
let qty = if row.at("Quantity", default: "") == "" { 0.0 } else { float(row.at("Quantity")) }
|
||||
let rate = if row.at("RateSubtotal", default: "") == "" { 0.0 } else { float(row.at("RateSubtotal")) }
|
||||
qty * rate
|
||||
})
|
||||
.sum(default: 0.00)
|
||||
|
||||
set text(size: 10pt)
|
||||
@@ -477,9 +481,9 @@
|
||||
[#counter(page).display("1/1", both: true)]
|
||||
)
|
||||
],
|
||||
background:
|
||||
background:
|
||||
place( top + left, dx: 15mm, dy: 25mm,
|
||||
format_table.at(schedule_type, default: bill_of_quantities_table)
|
||||
(format_table.at(schedule_type, default: bill_of_quantities_table))(currency: project_currency)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -522,9 +526,9 @@
|
||||
set page(
|
||||
background:
|
||||
place( top + left, dx: 15mm, dy: 25mm,
|
||||
format_table.at("SUMMARY")
|
||||
(format_table.at("SUMMARY"))(currency: project_currency)
|
||||
)
|
||||
)
|
||||
create-summary(schedule_path, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user