mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
396 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0ebdd53c9 | |||
| 8ece3790aa | |||
| 6585f0ee2b | |||
| 4ec042595e | |||
| 48f6e2908b | |||
| 1cd7e52c49 | |||
| 2e6f17ed0f | |||
| b549e65ad9 | |||
| ee5d672493 | |||
| 135f4cf023 | |||
| 608d9ead0e | |||
| a0ce930994 | |||
| eafa158ca0 | |||
| 5db955d40c | |||
| df27f86237 | |||
| 0aaafaedc9 | |||
| 7881f5992f | |||
| 679fe4dcae | |||
| a89621b179 | |||
| 110e4050c8 | |||
| 6dafb7a5c2 | |||
| 4cedeec813 | |||
| 6314d9c818 | |||
| 9bbd2b1854 | |||
| b5d36aacf6 | |||
| 6d3bed1f7d | |||
| 0096c0f6a2 | |||
| 661be0d66d | |||
| 62ed650b75 | |||
| da4b2f6eee | |||
| 00ec587296 | |||
| 2a05528b6d | |||
| a5f7f0cd93 | |||
| 1fd7329122 | |||
| f3e047d78e | |||
| 041306c5f0 | |||
| 5fba0026dd | |||
| 4d92a64206 | |||
| 9d2de117a9 | |||
| 6ee3c7a15f | |||
| fdf9970685 | |||
| de65e50fb5 | |||
| 714105b9fd | |||
| f0b5ab860f | |||
| 528964ca56 | |||
| 2c2d0f2434 | |||
| 340d4fb82a | |||
| 65cd5701c3 | |||
| febde1bbbb | |||
| 315835063c | |||
| 1b0a2fd6de | |||
| c95c1905ed | |||
| 301dae103c | |||
| 85cd1c1923 | |||
| 091fc9e7e5 | |||
| a65f291a89 | |||
| e6dc582d82 | |||
| 366fa67a84 | |||
| 4a8b863b96 | |||
| db0bbf6b84 | |||
| a757641b84 | |||
| 8bd22178f7 | |||
| b4d7780e14 | |||
| 7c092db9e6 | |||
| 38e3bb0590 | |||
| 7f49c945b9 | |||
| 81f71e6418 | |||
| 6318610bdb | |||
| 82dd1d94de | |||
| 4004344c20 | |||
| fce7cd3eb4 | |||
| f3dcf0b539 | |||
| f5ebbd1917 | |||
| 77aa080334 | |||
| 5afad2b179 | |||
| 98aa2c635e | |||
| 7db1ceb07f | |||
| 84abf5e918 | |||
| 741e8233ca | |||
| 262b55630c | |||
| f60a3423d0 | |||
| 4f2c4d1633 | |||
| 3b584ef242 | |||
| 10ee5aef4f | |||
| 59383f5010 | |||
| a9d6776875 | |||
| 1057f794f6 | |||
| b3218ced3f | |||
| 49d9c416b1 | |||
| 55e778f82e | |||
| 13681cdf9b | |||
| c592018b3f | |||
| dad4cc8a3c | |||
| 006a24ef32 | |||
| 6fa984ce2b | |||
| 262f5f9a85 | |||
| 44c0c2916c | |||
| 2ab5ca9222 | |||
| f710929e9e | |||
| a2dafc9ceb | |||
| 3c9ee4a71f | |||
| 27b0b920a9 | |||
| 6367de6102 | |||
| 3c20c27794 | |||
| 4f21bd1c69 | |||
| 312be203c9 | |||
| 7c9df9f980 | |||
| 669e04664d | |||
| c0f2fb1860 | |||
| 9396340b13 | |||
| e2a4e5692f | |||
| 104eeaf0cd | |||
| 260a387069 | |||
| 937270fc49 | |||
| 156c6183eb | |||
| 95fcf9e35c | |||
| 855de34d22 | |||
| 1e91eebf51 | |||
| d5bed316cd | |||
| 4d3bff4e3a | |||
| 0b0e34f18d | |||
| 6147a58d7a | |||
| 51eb8aece7 | |||
| 5cc9daa2f9 | |||
| a56b5660d0 | |||
| 5f1efeffaf | |||
| 074021de70 | |||
| ed7239526c | |||
| 7cd7db0c2b | |||
| 13c89ace83 | |||
| 6119f0045e | |||
| bbe437adc8 | |||
| 7dcf415ef1 | |||
| 3f8d7165e5 | |||
| ca1e2165e8 | |||
| 2f067187ee | |||
| 6a6756de66 | |||
| 22707fa534 | |||
| 3e7b739d8d | |||
| ca99ef3af7 | |||
| 55428a0878 | |||
| 682bd0a4f7 | |||
| 671217d494 | |||
| dcebf23af8 | |||
| bb8681a954 | |||
| c7d5d6c498 | |||
| 4d22a3fdb9 | |||
| a3593ed58b | |||
| b0eb55cc38 | |||
| f6590d8be2 | |||
| 3136c74c2f | |||
| 347a3c80bb | |||
| 92aa890add | |||
| 05c9df74f9 | |||
| 0c993d3292 | |||
| 32a601d057 | |||
| d1d1e1d4a2 | |||
| a7738eeb64 | |||
| a751fb956d | |||
| 251157f8d4 | |||
| a213a9b848 | |||
| b76bc1c1f8 | |||
| 4d24cef0c9 | |||
| 6c9cfccc43 | |||
| 0a0a5b9f04 | |||
| 3a0abbab95 | |||
| 0d703039a6 | |||
| f33df52c1b | |||
| 15a6375ea3 | |||
| 82465a64a5 | |||
| 6bde619fe6 | |||
| ba5321fdfa | |||
| 49ddc97918 | |||
| 39bcd9db63 | |||
| ab11ac5338 | |||
| 9346f45bba | |||
| b22687891b | |||
| 784f0b1fe2 | |||
| e0ceda6856 | |||
| 17951427fe | |||
| 734f4df84e | |||
| bfa2d789f1 | |||
| 192bf00d31 | |||
| ad672d0edb | |||
| 0df1f0cf49 | |||
| d7dd8ecf57 | |||
| becbcfdfe7 | |||
| 5b79cefee2 | |||
| 3346a59284 | |||
| 0bf8e9283f | |||
| db7591a867 | |||
| 7b9af9f533 | |||
| 704a2d36be | |||
| 516696cd73 | |||
| 93c6350e0f | |||
| a139adaa2c | |||
| a4a806147d | |||
| 4962e3256d | |||
| 06d99feeea | |||
| f584a50fbb | |||
| 24a241addc | |||
| 365be8fb52 | |||
| eacff93945 | |||
| 9d956f18b7 | |||
| 8faf9ff43d | |||
| 87bca20df7 | |||
| a30546f1f2 | |||
| fbe6fe5384 | |||
| bd264f1d85 | |||
| 674ed36e41 | |||
| 1f2b20fd86 | |||
| 94fab271cd | |||
| 8583d0963f | |||
| 77a2284f8a | |||
| 4520a72152 | |||
| 25651a1507 | |||
| 94faaa3160 | |||
| 0e922074b9 | |||
| 90ea256cc3 | |||
| 387bd51b4a | |||
| de4c394b50 | |||
| ab64b652ff | |||
| 99bb1e30ad | |||
| 1707c36bd8 | |||
| 44f5ee028f | |||
| ed7b2fc233 | |||
| 2c18155d98 | |||
| 1e8c0b86a0 | |||
| ac11044261 | |||
| ab9152e32d | |||
| 18dc7abb06 | |||
| b7549f2476 | |||
| ba6cfe9c24 | |||
| f158ae7377 | |||
| b154cadf3b | |||
| 5dde402f8b | |||
| 1daee04d9c | |||
| 0d7c378db5 | |||
| 1ba9341201 | |||
| f0aec7b38e | |||
| 4cf34b69d2 | |||
| 44723e83b6 | |||
| 2272c35e9b | |||
| 1c128a2d6a | |||
| 36372627db | |||
| a9b6f02f02 | |||
| 9e7d97e298 | |||
| 187b8e7167 | |||
| 08e33fe572 | |||
| e142b9d7b4 | |||
| de29d12b00 | |||
| 9440bafc32 | |||
| 431cf435ef | |||
| a433f56337 | |||
| 0d3543fa31 | |||
| e764559133 | |||
| 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 | |||
| 4adaf0d61f | |||
| e392d2da6e | |||
| 5febbc1391 | |||
| 6b2d25a5e5 | |||
| 2d05398b1c | |||
| 32a7de66de | |||
| 29fe41edd0 | |||
| 760c65595c | |||
| 29b648d8dd | |||
| 3ffdb9e74d | |||
| 00915409ac | |||
| d21543a24a | |||
| 9246be710c | |||
| 3205a4ebb1 | |||
| e82c087b5e | |||
| e6258ab4a8 | |||
| 5db65f4041 | |||
| 89ce32fdfd | |||
| 763a31a31d | |||
| 4b8c612647 |
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "PyGithub",
|
||||
# "requests",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from github import Github
|
||||
from github.GitReleaseAsset import GitReleaseAsset
|
||||
|
||||
EXTENSION_ID = "bonsai"
|
||||
CURRENT_PYTHON_VERSION = "py313"
|
||||
CURRENT_PLATFORMS = ["linux-x64", "macos-arm64", "windows-x64"]
|
||||
|
||||
|
||||
def publish_asset(asset: GitReleaseAsset, token: str, repo_root: Path) -> None:
|
||||
"""
|
||||
Publish an asset to Blender Extensions.
|
||||
Reference: https://extensions.blender.org/api/v1/swagger
|
||||
"""
|
||||
temp_path = repo_root / asset.name
|
||||
|
||||
response = requests.get(asset.browser_download_url)
|
||||
response.raise_for_status()
|
||||
temp_path.write_bytes(response.content)
|
||||
|
||||
url = f"https://extensions.blender.org/api/v1/extensions/{EXTENSION_ID}/versions/upload/"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
files = {"version_file": temp_path.read_bytes()}
|
||||
response = requests.post(url, headers=headers, files=files)
|
||||
response.raise_for_status()
|
||||
|
||||
temp_path.unlink()
|
||||
|
||||
print(f"✓ Published {asset.name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.getenv("BLENDER_EXTENSIONS_TOKEN")
|
||||
if not token:
|
||||
raise Exception("BLENDER_EXTENSIONS_TOKEN environment variable not set")
|
||||
|
||||
# Get the repository root
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
|
||||
# Read VERSION file
|
||||
version_file = repo_root / "VERSION"
|
||||
version = version_file.read_text().strip()
|
||||
|
||||
print(f"Current VERSION: {version}")
|
||||
|
||||
tag_name = f"bonsai-{version}"
|
||||
|
||||
# Get release from GitHub
|
||||
gh = Github()
|
||||
gh_repo = gh.get_repo("IfcOpenShell/IfcOpenShell")
|
||||
release = gh_repo.get_release(tag_name)
|
||||
|
||||
assets = release.get_assets()
|
||||
|
||||
asset_platform_map: dict[str, tuple[GitReleaseAsset, str]] = {}
|
||||
for asset in assets:
|
||||
if CURRENT_PYTHON_VERSION not in asset.name:
|
||||
continue
|
||||
for platform in CURRENT_PLATFORMS:
|
||||
if platform in asset.name:
|
||||
asset_platform_map[asset.name] = (asset, platform)
|
||||
break
|
||||
|
||||
if len(asset_platform_map) != len(CURRENT_PLATFORMS):
|
||||
found_platforms = {platform for _, (_, platform) in asset_platform_map.items()}
|
||||
missing_platforms = set(CURRENT_PLATFORMS) - found_platforms
|
||||
raise Exception(
|
||||
f"Expected {len(CURRENT_PLATFORMS)} assets but found {len(asset_platform_map)}. "
|
||||
f"Missing: {', '.join(sorted(missing_platforms))}"
|
||||
)
|
||||
|
||||
print("\nRelease assets:")
|
||||
for asset_name in sorted(asset_platform_map.keys()):
|
||||
print(f"- {asset_name}")
|
||||
|
||||
# https://extensions.blender.org/api/v1/swagger
|
||||
print("\nPublishing assets to Blender Extensions:")
|
||||
for asset_name, (asset, platform) in asset_platform_map.items():
|
||||
publish_asset(asset, token, repo_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ jobs:
|
||||
container: rockylinux:9
|
||||
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Install Python
|
||||
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
|
||||
run: uv python install
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
@@ -17,7 +24,6 @@ jobs:
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Install aws cli
|
||||
@@ -45,10 +51,10 @@ jobs:
|
||||
- name: Unpack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
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
|
||||
|
||||
@@ -56,7 +62,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
|
||||
- name: Upload Build Logs
|
||||
if: always()
|
||||
@@ -71,7 +77,7 @@ jobs:
|
||||
- name: Pack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
python3 ../nix/cache_dependencies.py pack
|
||||
uv run ../nix/cache_dependencies.py pack
|
||||
|
||||
- name: Commit and Push Changes to Build Repository
|
||||
run: |
|
||||
|
||||
@@ -9,6 +9,13 @@ jobs:
|
||||
container: arm64v8/rockylinux:9
|
||||
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Install Python
|
||||
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
|
||||
run: uv python install
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
@@ -17,7 +24,6 @@ jobs:
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Install aws cli
|
||||
@@ -45,10 +51,10 @@ jobs:
|
||||
- name: Unpack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
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
|
||||
|
||||
@@ -56,7 +62,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
|
||||
- name: Upload Build Logs
|
||||
if: always()
|
||||
@@ -71,7 +77,7 @@ jobs:
|
||||
- name: Pack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
python3 ../nix/cache_dependencies.py pack
|
||||
uv run ../nix/cache_dependencies.py pack
|
||||
|
||||
- name: Commit and Push Changes to Build Repository
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
name: ci-ifcwrap-standalone
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-ifcwrap-standalone.yml"
|
||||
- "cmake/**"
|
||||
- "src/ifcwrap/**"
|
||||
- "src/ifcparse/**"
|
||||
- "src/ifcgeom/**"
|
||||
- "src/serializers/**"
|
||||
- "src/ifcconvert/**"
|
||||
- "src/ifcopenshell-python/**"
|
||||
- "src/svgfill/**"
|
||||
push:
|
||||
paths:
|
||||
- ".github/workflows/ci-ifcwrap-standalone.yml"
|
||||
- "cmake/**"
|
||||
- "src/ifcwrap/**"
|
||||
- "src/ifcparse/**"
|
||||
- "src/ifcgeom/**"
|
||||
- "src/serializers/**"
|
||||
- "src/ifcconvert/**"
|
||||
- "src/ifcopenshell-python/**"
|
||||
- "src/svgfill/**"
|
||||
|
||||
env:
|
||||
IFCOPENSHELL_PREFIX: ${{ github.workspace }}/ifcopenshell-install
|
||||
|
||||
jobs:
|
||||
build-ifcopenshell:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install C++ dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
cmake \
|
||||
gcc \
|
||||
g++ \
|
||||
libboost-date-time-dev \
|
||||
libboost-filesystem-dev \
|
||||
libboost-iostreams-dev \
|
||||
libboost-program-options-dev \
|
||||
libboost-regex-dev \
|
||||
libboost-system-dev \
|
||||
libboost-thread-dev \
|
||||
libeigen3-dev \
|
||||
libocct-data-exchange-dev \
|
||||
libocct-draw-dev \
|
||||
libocct-foundation-dev \
|
||||
libocct-modeling-algorithms-dev \
|
||||
libocct-modeling-data-dev \
|
||||
libocct-ocaf-dev \
|
||||
libocct-visualization-dev \
|
||||
libpcre3-dev \
|
||||
libtbb-dev \
|
||||
libxml2-dev \
|
||||
libxi-dev \
|
||||
occt-misc \
|
||||
tcl-dev \
|
||||
tk-dev \
|
||||
swig
|
||||
|
||||
- name: Configure minimal IfcOpenShell
|
||||
run: |
|
||||
cmake -S cmake -B build-ifcopenshell \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="${IFCOPENSHELL_PREFIX}" \
|
||||
-DCMAKE_PREFIX_PATH=/usr \
|
||||
-DCMAKE_SYSTEM_PREFIX_PATH=/usr \
|
||||
-DMINIMAL_BUILD=ON \
|
||||
-DBUILD_IFCPYTHON=OFF \
|
||||
"-DSCHEMA_VERSIONS=4x3_add2"
|
||||
|
||||
- name: Build and install minimal IfcOpenShell
|
||||
run: |
|
||||
cmake --build build-ifcopenshell --target install -j "$(nproc)"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install Python import dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install numpy typing_extensions
|
||||
|
||||
- name: Configure standalone IfcPython
|
||||
run: |
|
||||
PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
|
||||
PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
|
||||
|
||||
cmake -S src/ifcwrap -B "build-ifcwrap-311" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
|
||||
-DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
|
||||
-DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
|
||||
|
||||
- name: Build and install standalone IfcPython
|
||||
run: |
|
||||
cmake --build "build-ifcwrap-311" --target install -j "$(nproc)"
|
||||
|
||||
- name: Import installed IfcPython
|
||||
run: |
|
||||
PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
|
||||
import ifcopenshell
|
||||
|
||||
print("IfcOpenShell import ok:", ifcopenshell.version)
|
||||
PY
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install Python import dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install numpy typing_extensions
|
||||
|
||||
- name: Configure standalone IfcPython
|
||||
run: |
|
||||
PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
|
||||
PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
|
||||
|
||||
cmake -S src/ifcwrap -B "build-ifcwrap-312" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
|
||||
-DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
|
||||
-DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
|
||||
|
||||
- name: Build and install standalone IfcPython
|
||||
run: |
|
||||
cmake --build "build-ifcwrap-312" --target install -j "$(nproc)"
|
||||
|
||||
- name: Import installed IfcPython
|
||||
run: |
|
||||
PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
|
||||
import ifcopenshell
|
||||
|
||||
print("IfcOpenShell import ok:", ifcopenshell.version)
|
||||
PY
|
||||
@@ -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
|
||||
@@ -95,8 +95,7 @@ jobs:
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
}
|
||||
|
||||
run_check poe ruff-main
|
||||
run_check poe ruff-old
|
||||
run_check poe ruff
|
||||
|
||||
exit $ERROR
|
||||
continue-on-error: true
|
||||
|
||||
@@ -10,9 +10,13 @@ on:
|
||||
- 'src/ifcgeomserver/**'
|
||||
- 'src/ifcjni/**'
|
||||
- 'src/ifcmax/**'
|
||||
- 'src/ifc5d/**'
|
||||
- 'src/ifcedit/**'
|
||||
- 'src/ifcmcp/**'
|
||||
- 'src/ifcopenshell-python/**'
|
||||
- '!src/ifcopenshell-python/docs/**'
|
||||
- 'src/ifcparse/**'
|
||||
- 'src/ifcquery/**'
|
||||
- 'src/ifcwrap/**'
|
||||
- 'src/qtviewer/**'
|
||||
- 'src/svgfill/**'
|
||||
@@ -51,7 +55,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 psutil
|
||||
pip install src/bcf --no-deps
|
||||
pip install pytest-xdist==3.8.0
|
||||
|
||||
@@ -79,7 +83,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 }}
|
||||
|
||||
@@ -252,13 +256,26 @@ jobs:
|
||||
pip install deepdiff
|
||||
cd ../ifcdiff && make test || ERROR=1
|
||||
cd ../ifcpatch && make test || ERROR=1
|
||||
pip install -e ../ifc5d --no-deps
|
||||
pip install odfpy xlsxwriter
|
||||
cd ../ifc5d && make test || ERROR=1
|
||||
pip install -e ../ifcquery --no-deps
|
||||
cd ../ifcquery && make test || ERROR=1
|
||||
pip install -e ../ifcedit --no-deps
|
||||
cd ../ifcedit && make test || ERROR=1
|
||||
pip install mcp
|
||||
pip install -e ../ifcmcp --no-deps
|
||||
cd ../ifcmcp && make test || ERROR=1
|
||||
pip install -e ../ifctester --no-deps
|
||||
cd ../ifctester && make test || ERROR=1
|
||||
make build-ids-docs || ERROR=1
|
||||
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
|
||||
# mathutils only has pre-built wheels for Python 3.13+; skip on older versions.
|
||||
cd ../ifcopenshell-python
|
||||
pip install mathutils
|
||||
make test-mathutils || ERROR=1
|
||||
if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)"; then
|
||||
pip install mathutils
|
||||
make test-mathutils || ERROR=1
|
||||
fi
|
||||
if [ $ERROR -ne 0 ]; then
|
||||
echo "One or more tests failed";
|
||||
exit 1;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Build and Deploy Stable Documentation
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/bonsai/docs
|
||||
pip install -r requirements.txt # Run pip install from the docs directory
|
||||
|
||||
- name: Build documentation
|
||||
run: |
|
||||
cd src/bonsai/docs
|
||||
make html
|
||||
|
||||
- name: Deploy to GitHub Pages (Stable)
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
|
||||
external_repository: IfcOpenShell/bonsaibim_org_docs
|
||||
publish_branch: main
|
||||
cname: docs.bonsaibim.org
|
||||
publish_dir: src/bonsai/docs/_build/html
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Publish Bonsai Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- run: uv run .github/scripts/publish-bonsai-releases.py
|
||||
env:
|
||||
BLENDER_EXTENSIONS_TOKEN: ${{ secrets.BLENDER_EXTENSIONS_TOKEN }}
|
||||
@@ -28,6 +28,7 @@ venv
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
.vs
|
||||
/*.code-workspace
|
||||
|
||||
# PyCharm files
|
||||
.idea
|
||||
@@ -128,3 +129,4 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
||||
*.claude
|
||||
*.py.tmp*
|
||||
*.json.tmp*
|
||||
|
||||
|
||||
@@ -129,6 +129,21 @@ on CI to catch formatting issues.
|
||||
within each package under `src/`.
|
||||
- Run the existing test suite for the package you modified before submitting.
|
||||
|
||||
## In-Progress Feature Notes
|
||||
|
||||
Living design and working notes for unmerged feature branches live in
|
||||
[`docs/dev-notes/`](docs/dev-notes/), one Markdown file per feature, named after the
|
||||
branch. They capture the problem, the design decisions and the *why*, and what still
|
||||
needs testing — so collaborators (and their AI agents) can pick up the context behind a
|
||||
branch. Because the note is committed on the branch, it travels with the PR.
|
||||
|
||||
- Before working on a feature branch, read its note in `docs/dev-notes/` if one exists.
|
||||
- Keep the note current as the PR is refined.
|
||||
- These are not user documentation; at merge they are removed or their durable parts
|
||||
promoted to code comments / permanent docs.
|
||||
|
||||
See [`docs/dev-notes/README.md`](docs/dev-notes/README.md) for details.
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
### Directory Structure
|
||||
|
||||
@@ -53,11 +53,11 @@ Contents
|
||||
| [ifcedit](https://docs.ifcopenshell.org/ifcedit.html) | CLI wrapper for ifcopenshell.api IFC model mutation functions | LGPL-3.0-or-later | [](https://pypi.org/project/ifcedit/) |
|
||||
| [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [](https://pypi.org/project/ifcfm/) |
|
||||
| [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcmax.html)
|
||||
| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcmcp/) |
|
||||
| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcopenshell-mcp/) |
|
||||
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [](https://pypi.org/project/ifcopenshell/) [](https://anaconda.org/conda-forge/ifcopenshell) [](https://anaconda.org/ifcopenshell/ifcopenshell) [](https://hub.docker.com/r/aecgeeks/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell-git) [](https://github.com/IfcOpenShell/wasm-wheels) |
|
||||
| [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [](https://pypi.org/project/ifcpatch/) |
|
||||
| [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcquery/) |
|
||||
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
|
||||
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
|
||||
| [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [](https://pypi.org/project/ifctester/) |
|
||||
|
||||
The IfcOpenShell C++ codebase is split into multiple interal libraries:
|
||||
|
||||
@@ -258,10 +258,14 @@ if(WITH_ROCKSDB)
|
||||
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
|
||||
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
|
||||
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
|
||||
# So rocksdb supported only as a static library.
|
||||
# See https://github.com/facebook/rocksdb/issues/981.
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
if(TARGET RocksDB::rocksdb)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
elseif(TARGET RocksDB::rocksdb-shared)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb-shared)
|
||||
else()
|
||||
message(FATAL_ERROR "RocksDB found but neither RocksDB::rocksdb nor RocksDB::rocksdb-shared target exists")
|
||||
endif()
|
||||
|
||||
if(WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
|
||||
@@ -88,7 +88,15 @@ if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
|
||||
mark_as_advanced(HDF5_DIR)
|
||||
if(HDF5_DIR)
|
||||
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
if(TARGET hdf5_cpp-static)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
elseif(TARGET hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-shared)
|
||||
elseif(TARGET hdf5::hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5::hdf5_cpp-shared)
|
||||
else()
|
||||
find_package(HDF5 REQUIRED COMPONENTS CXX)
|
||||
endif()
|
||||
else()
|
||||
# If it failed, still try to find as a module.
|
||||
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
################################################################################
|
||||
# #
|
||||
# This file is part of IfcOpenShell. #
|
||||
# #
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the Lesser GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3.0 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# IfcOpenShell is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# Lesser GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the Lesser GNU General Public License #
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
################################################################################
|
||||
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/utilities.cmake" OPTIONAL)
|
||||
|
||||
set(_IfcOpenShell_find_args)
|
||||
if(IfcOpenShell_FIND_VERSION)
|
||||
list(APPEND _IfcOpenShell_find_args "${IfcOpenShell_FIND_VERSION}")
|
||||
if(IfcOpenShell_FIND_VERSION_EXACT)
|
||||
list(APPEND _IfcOpenShell_find_args EXACT)
|
||||
endif()
|
||||
endif()
|
||||
list(APPEND _IfcOpenShell_find_args CONFIG QUIET)
|
||||
if(IfcOpenShell_FIND_COMPONENTS)
|
||||
list(APPEND _IfcOpenShell_find_args COMPONENTS ${IfcOpenShell_FIND_COMPONENTS})
|
||||
endif()
|
||||
|
||||
set(_IfcOpenShell_saved_module_path "${CMAKE_MODULE_PATH}")
|
||||
list(REMOVE_ITEM CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}")
|
||||
find_package(IfcOpenShell ${_IfcOpenShell_find_args})
|
||||
set(CMAKE_MODULE_PATH "${_IfcOpenShell_saved_module_path}")
|
||||
|
||||
if(NOT IfcOpenShell_FOUND)
|
||||
set(_IfcOpenShell_error "Could not find an IfcOpenShell CMake config package. Set IfcOpenShell_DIR or CMAKE_PREFIX_PATH.")
|
||||
if(IfcOpenShell_FIND_REQUIRED)
|
||||
message(FATAL_ERROR "${_IfcOpenShell_error}")
|
||||
elseif(NOT IfcOpenShell_FIND_QUIETLY)
|
||||
message(STATUS "${_IfcOpenShell_error}")
|
||||
endif()
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(_IfcOpenShell_required_targets IfcOpenShell::IfcParse IfcOpenShell::IfcGeom)
|
||||
set(_IfcOpenShell_missing_targets "")
|
||||
foreach(_IfcOpenShell_target IN LISTS _IfcOpenShell_required_targets)
|
||||
if(NOT TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND _IfcOpenShell_missing_targets ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_IfcOpenShell_missing_targets)
|
||||
set(IfcOpenShell_FOUND FALSE)
|
||||
string(REPLACE ";" ", " _IfcOpenShell_missing_targets_text "${_IfcOpenShell_missing_targets}")
|
||||
set(_IfcOpenShell_error "IfcOpenShell config was found, but required targets are missing: ${_IfcOpenShell_missing_targets_text}.")
|
||||
if(IfcOpenShell_FIND_REQUIRED)
|
||||
message(FATAL_ERROR "${_IfcOpenShell_error}")
|
||||
elseif(NOT IfcOpenShell_FIND_QUIETLY)
|
||||
message(STATUS "${_IfcOpenShell_error}")
|
||||
endif()
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_OPENCASCADE)
|
||||
set(IFCOPENSHELL_WITH_OPENCASCADE OFF)
|
||||
if(TARGET IfcOpenShell::geometry_kernel_opencascade)
|
||||
set(IFCOPENSHELL_WITH_OPENCASCADE ON)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_CGAL)
|
||||
set(IFCOPENSHELL_WITH_CGAL OFF)
|
||||
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
set(IFCOPENSHELL_WITH_CGAL ON)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_IFCXML)
|
||||
set(IFCOPENSHELL_IFCXML OFF)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_ROCKSDB)
|
||||
set(IFCOPENSHELL_WITH_ROCKSDB OFF)
|
||||
endif()
|
||||
|
||||
set(IFCOPENSHELL_LIBRARIES IfcOpenShell::IfcParse)
|
||||
foreach(_IfcOpenShell_target IN ITEMS IfcOpenShell::geometry_serializer IfcOpenShell::Serializers)
|
||||
if(TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND IFCOPENSHELL_LIBRARIES ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(IFCOPENSHELL_KERNEL_LIBRARIES "")
|
||||
foreach(_IfcOpenShell_target IN ITEMS
|
||||
IfcOpenShell::geometry_kernel_opencascade
|
||||
IfcOpenShell::geometry_kernel_cgal
|
||||
IfcOpenShell::geometry_kernel_cgal_simple
|
||||
)
|
||||
if(TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND IFCOPENSHELL_KERNEL_LIBRARIES ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(IFCOPENSHELL_GEOMETRY_LIBRARIES IfcOpenShell::IfcGeom ${IFCOPENSHELL_KERNEL_LIBRARIES})
|
||||
|
||||
if(TARGET IfcOpenShell::OpenCASCADE_INTERFACE)
|
||||
set(OpenCASCADE_LIBRARIES IfcOpenShell::OpenCASCADE_INTERFACE)
|
||||
endif()
|
||||
|
||||
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
set(CGAL_LIBRARIES IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
endif()
|
||||
|
||||
if(TARGET IfcOpenShell::svgfill)
|
||||
set(IFCOPENSHELL_SVGFILL_LIBRARY IfcOpenShell::svgfill)
|
||||
endif()
|
||||
|
||||
mark_as_advanced(IfcOpenShell_DIR)
|
||||
|
||||
unset(_IfcOpenShell_error)
|
||||
unset(_IfcOpenShell_find_args)
|
||||
unset(_IfcOpenShell_missing_targets)
|
||||
unset(_IfcOpenShell_missing_targets_text)
|
||||
unset(_IfcOpenShell_required_targets)
|
||||
unset(_IfcOpenShell_target)
|
||||
@@ -7,12 +7,26 @@ set(IFCOPENSHELL_WITH_OPENCASCADE @WITH_OPENCASCADE@)
|
||||
set(IFCOPENSHELL_WITH_CGAL @WITH_CGAL@)
|
||||
set(IFCOPENSHELL_IFCXML @IFCXML_SUPPORT@)
|
||||
set(IFCOPENSHELL_WITH_ROCKSDB @WITH_ROCKSDB@)
|
||||
set(IFCOPENSHELL_COLLADA_SUPPORT @COLLADA_SUPPORT@)
|
||||
set(IFCOPENSHELL_GLTF_SUPPORT @GLTF_SUPPORT@)
|
||||
set(IFCOPENSHELL_HDF5_SUPPORT @HDF5_SUPPORT@)
|
||||
set(IFCOPENSHELL_WITH_PROJ @WITH_PROJ@)
|
||||
set(IFCOPENSHELL_USD_SUPPORT @USD_SUPPORT@)
|
||||
|
||||
include(CMakeFindDependencyMacro)
|
||||
|
||||
set(Boost_USE_STATIC_LIBS ON)
|
||||
set(Boost_USE_STATIC_RUNTIME OFF)
|
||||
set(Boost_USE_MULTITHREADED ON)
|
||||
set(IFCOPENSHELL_BOOST_USE_STATIC_LIBS "@Boost_USE_STATIC_LIBS@")
|
||||
set(IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME "@Boost_USE_STATIC_RUNTIME@")
|
||||
set(IFCOPENSHELL_BOOST_USE_MULTITHREADED "@Boost_USE_MULTITHREADED@")
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}" STREQUAL "")
|
||||
set(Boost_USE_STATIC_LIBS ${IFCOPENSHELL_BOOST_USE_STATIC_LIBS})
|
||||
endif()
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}" STREQUAL "")
|
||||
set(Boost_USE_STATIC_RUNTIME ${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME})
|
||||
endif()
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_MULTITHREADED}" STREQUAL "")
|
||||
set(Boost_USE_MULTITHREADED ${IFCOPENSHELL_BOOST_USE_MULTITHREADED})
|
||||
endif()
|
||||
set(Boost_COMPONENTS
|
||||
system
|
||||
program_options
|
||||
@@ -43,13 +57,33 @@ if(IFCOPENSHELL_WITH_ROCKSDB)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_IFCXML)
|
||||
find_dependency(LibXml2 CONFIG)
|
||||
find_dependency(LibXml2)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_CGAL)
|
||||
find_dependency(CGAL CONFIG)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_COLLADA_SUPPORT)
|
||||
find_dependency(OpenCOLLADA)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_GLTF_SUPPORT)
|
||||
find_dependency(nlohmann_json CONFIG)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_HDF5_SUPPORT)
|
||||
find_dependency(HDF5 COMPONENTS C CXX)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_PROJ)
|
||||
find_dependency(PROJ)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_USD_SUPPORT)
|
||||
find_dependency(USD)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_OPENCASCADE)
|
||||
find_dependency(OpenCASCADE CONFIG)
|
||||
if(OpenCASCADE_VERSION VERSION_LESS "7.7.0")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Developer notes (in-progress features)
|
||||
|
||||
This directory holds **living design/working notes for unmerged feature branches**,
|
||||
one Markdown file per feature, named after its branch (e.g.
|
||||
`opening-template-on-type.md`).
|
||||
|
||||
## Purpose
|
||||
|
||||
A shared scratchpad so collaborators — and the AI agents they work with — can pick up
|
||||
the context behind an in-progress branch: the problem, the design decisions and the
|
||||
*why*, dead ends already ruled out, and what still needs testing. Because the note is
|
||||
committed on the branch, it travels with the PR and shows up in the diff, so it is
|
||||
discoverable without anyone being told where to look.
|
||||
|
||||
## How to use it (humans and agents)
|
||||
|
||||
- **Before working on a feature branch**, read its note here if one exists.
|
||||
- **As the PR is refined**, keep the note current — append decisions, correct things
|
||||
that changed, update the test checklist.
|
||||
- **One file per feature**, named after the branch.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
These are *not* permanent user documentation. When a PR merges, either remove its note
|
||||
or promote the durable parts (the load-bearing "why") into code comments or the regular
|
||||
docs, so stale notes do not accumulate on the default branch.
|
||||
@@ -0,0 +1,135 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Occurrence representations — normalize occurrence-local reps onto the type
|
||||
|
||||
> **Living dev note** for the `occurrence-representations` branch/PR. Read before working
|
||||
> on the feature; append decisions and findings as the PR is refined. This is *not* user
|
||||
> documentation — at merge it is removed or its durable parts promoted to code comments.
|
||||
> See [README.md](README.md) for the convention.
|
||||
|
||||
Tracking issue: [#8788](https://github.com/IfcOpenShell/IfcOpenShell/issues/8788). Supersedes
|
||||
the closed PR #8789 (see "History / pivot"). Stacked on `dev-notes-system` (#8201) →
|
||||
`opening-template-on-type` (#8200) → `select-by-representation-type` (#7916), because it shares
|
||||
the Representations panel and `RepresentationsData`.
|
||||
|
||||
## Position (maintainer-aligned)
|
||||
|
||||
Per Dion Moult (project lead), **if a type has representations, its occurrences should share
|
||||
them** — an occurrence carrying a representation the type lacks is an *anomaly to normalize up
|
||||
to the type*, not something to preserve. This follows the MVD concept-template intent (mapped
|
||||
representations mirror the type relationship) and the `IfcTypeProduct` text that typed
|
||||
occurrences "have to reference the representation maps", even though EXPRESS has no WHERE rule
|
||||
enforcing it. See the buildingSMART thread Moult started:
|
||||
<https://forums.buildingsmart.org/t/must-mappedrepresentations-come-from-the-corresponding-ifc-type/3361>.
|
||||
|
||||
This branch therefore provides the **normalization path**, and deliberately does *not* try to
|
||||
make occurrence-local reps a first-class, persisted thing.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
1. **Promote to Type** (`bim.promote_representation_to_type`) — lift an occurrence-local rep
|
||||
onto its type as a `RepresentationMap`, so occurrences inherit it. The migration tool for
|
||||
imported/legacy models (Revit et al. emit occurrence-only / partial-from-type reps).
|
||||
2. **Type / Occurrence panel split** — `BIM_PT_representations` groups rows under **Type**
|
||||
(mapped/inherited) vs **Occurrence** (local) headers, so an anomalous occurrence-local rep
|
||||
is *surfaced* instead of silent.
|
||||
|
||||
**Deliberately out (dropped from the earlier draft):**
|
||||
|
||||
- **Copy-time preservation** — `copy_class` is left as-is (occurrence-only reps are not
|
||||
re-added on duplicate). Preserving them perpetuates the anomaly; normalize first, then copy.
|
||||
- **"Add to Occurrence" toggle** — removed. `add_representation` keeps stock behaviour
|
||||
(`geometry.assign_representation` already redirects a new rep onto the type when the type has
|
||||
maps). No force-local override.
|
||||
|
||||
## Design
|
||||
|
||||
### Promote to Type (slot-based, "type wins")
|
||||
|
||||
`bim.promote_representation_to_type` (`EXPORT` icon on Occurrence rows, only when
|
||||
`element_has_type`) → `core.geometry.promote_representation_to_type`. Copies the promoted rep
|
||||
onto the type as a new `RepresentationMap` (`tool.Geometry.add_type_representation_map`), then for
|
||||
**every** occurrence of the type: removes **any** existing rep in the same **slot** and assigns
|
||||
the type's mapped rep in its place. Occurrences with no rep in the slot simply inherit it.
|
||||
|
||||
"Any existing rep" is the load-bearing part: it covers a **local** (non-mapped) rep *and* an
|
||||
already-**mapped** rep the occurrence inherited from another map — e.g. a floating
|
||||
`IfcRepresentationMap` not anchored to the type, which Revit emits (each occurrence maps to its
|
||||
own or a shared floating map, the type's `RepresentationMaps` is empty). Local reps are removed
|
||||
via `core.remove_representation` (Blender-aware); mapped reps via a per-occurrence
|
||||
`geometry.unassign_representation` + `geometry.remove_representation` (its `remove_deep2` keeps a
|
||||
shared map alive until its last user is gone, so floating maps get garbage-collected). If the
|
||||
type already holds a rep in the slot it is removed too, so promoting is idempotent (replaces
|
||||
rather than accumulating maps). The slot key resolves through mapped items
|
||||
(`resolve_mapped_representation`), because an inherited rep's own `RepresentationType` is
|
||||
`"MappedRepresentation"`, not the underlying type.
|
||||
|
||||
The slot key is context (context/subcontext/target view) + `RepresentationIdentifier` +
|
||||
resolved `RepresentationType`. **Geometry is not compared** — the type's representation replaces the
|
||||
occurrence's for that slot even when the occurrence's geometry genuinely differs (e.g. an
|
||||
independently meshed / mirrored / rotated Revit instance), so such occurrences visibly adopt the
|
||||
type's geometry. The mapped rep uses `map_representation`'s identity transform, so a divergent
|
||||
instance takes the type geometry at *its own placement* (baked per-instance mesh orientation is
|
||||
lost — the accepted tradeoff of "type wins").
|
||||
|
||||
Rationale for dropping the earlier geometry comparison: for Revit-style imports each occurrence
|
||||
carries an independently tessellated body (same vertex count but reordered + reoriented; no
|
||||
single affine maps one to another, confirmed via a least-squares fit — `max_err ≈ 1.3 m`), so an
|
||||
"only consolidate byte-identical" rule left most real-world duplicates unconsolidated. Slot-based
|
||||
replace is the deliberate, user-chosen behaviour.
|
||||
|
||||
### Panel split
|
||||
|
||||
`RepresentationsData` (geometry/data.py) exposes `is_mapped`
|
||||
(`resolve_representation(rep) != rep`), `element_is_type`, and `element_has_type`.
|
||||
`draw_representation_row` is shared and carries the stack's `RepresentationIdentifier` column +
|
||||
`select_by_representation_type` button; the Occurrence-group rows additionally show the promote
|
||||
button. A type element shows a flat list.
|
||||
|
||||
## The divergent-occurrence case (decision made)
|
||||
|
||||
Two occurrences of one type that carry **different** geometry in the same slot cannot both live
|
||||
on the type (one mapped rep per slot). The chosen resolution is **"type wins"**: promote
|
||||
replaces every occurrence's local rep in that slot with the type's, discarding divergent
|
||||
per-instance geometry. This favours a single authoritative type geometry over preserving
|
||||
independently-authored instance bodies. (Intrinsic per-instance geometry — voids/joins;
|
||||
`IfcRelVoidsElement` is occurrence-only — lives in a *different* mechanism and is unaffected.)
|
||||
The broader "can occurrences ever legitimately diverge" question is still worth raising with
|
||||
Moult on #8788, but Promote no longer tries to adjudicate it.
|
||||
|
||||
## Status — implemented (verified in live Blender)
|
||||
|
||||
- `tool/geometry.py`: `copy_representation_deep`, `add_type_representation_map`.
|
||||
- `core/geometry.py`: `promote_representation_to_type` (slot-based).
|
||||
- `core/tool.py`: interface decls for the two new `Geometry` methods.
|
||||
- `bim/module/geometry/operator.py`: `PromoteRepresentationToType`.
|
||||
- `bim/module/geometry/{data,ui}.py`: `is_mapped` / `element_is_type` / `element_has_type`;
|
||||
Type/Occurrence grouping merged with the stack's panel columns; old `*` suffix removed.
|
||||
- `bim/module/geometry/__init__.py`: register `PromoteRepresentationToType`.
|
||||
- `core/root.py`, `tool/root.py`, `core/geometry.py::add_representation`: reverted to base
|
||||
(copy-preservation + add-to-occurrence removed).
|
||||
|
||||
## History / pivot
|
||||
|
||||
Originally four pieces incl. a `copy_class` fix that re-added occurrence-only reps on duplicate,
|
||||
and an "Add to Occurrence" toggle. PR #8789 was closed by Moult as "based on the wrong premise
|
||||
— there shouldn't be representations on occurrence and not on type if the type has
|
||||
representations." Re-scoped to the normalization-only subset above; copy-preservation and the
|
||||
toggle removed.
|
||||
|
||||
## Things to test / verify
|
||||
|
||||
- Promote (verified on a Revit sink type, 5 occurrences): every occurrence ends up referencing
|
||||
the type's mapped rep — occurrences with a local body in the slot have it replaced (including
|
||||
independently-meshed/mirrored ones, which visibly adopt the type geometry), and occurrences
|
||||
with none inherit it. Exercises `remove_representation`'s Blender mesh/data-link side effects.
|
||||
- Promoting a second slot (e.g. Body/PLAN_VIEW/Curve3D) adds a second `RepresentationMap` and all
|
||||
occurrences inherit both.
|
||||
- Re-open the saved IFC and confirm the mapped instances render sensibly (the divergent ones will
|
||||
have changed orientation — that's the accepted "type wins" tradeoff, not a bug).
|
||||
- Panel: Type vs Occurrence grouping correct for occurrence, typed occurrence with no local
|
||||
reps (only Type header), typeless element (only Occurrence), and a type element (flat list);
|
||||
columns still align with the stack's header row.
|
||||
- Confirm copy/add behave as stock v0.8.0 (no regression from the removed pieces).
|
||||
@@ -0,0 +1,165 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Opening template on type — preserving custom openings across duplicate_type / append
|
||||
|
||||
> **Living dev note** for the `opening-template-on-type` branch/PR. Read before working
|
||||
> on the feature; append decisions and findings as the PR is refined. This is *not* user
|
||||
> documentation — at merge it is removed or its durable parts promoted to code comments.
|
||||
> See [README.md](README.md) for the convention.
|
||||
|
||||
## Problem
|
||||
|
||||
`bpy.ops.bim.duplicate_type` and `bpy.ops.bim.append_library_element` lose a custom
|
||||
`IfcOpeningElement` body (e.g. an `IfcPolygonalFaceSet`/tessellation) and replace it
|
||||
with a generated extrusion. Root cause: the only mechanism that preserved a custom
|
||||
opening was "copy it from a sibling occurrence of the same type"
|
||||
(`get_existing_opening_occurrence_if_any`), which returns nothing for a brand-new
|
||||
type. `generate_opening_from_filling` then always builds an extrusion (profile or
|
||||
bbox), discarding the custom geometry.
|
||||
|
||||
## Key facts established
|
||||
|
||||
- IFC-level `root.copy_class` already `copy_deep`s opening representations; the loss
|
||||
happens on the Bonsai side (the `regenerate_from_type` listener on
|
||||
`type.assign_type`, and placement-time generation).
|
||||
- Opening occurrences of one type already **share** a single `IfcRepresentationMap`
|
||||
via mapped representations — that is why editing one void edits them all
|
||||
(see `tool.Model.unshare_opening_representation` docstring). Bonsai shares, it does
|
||||
not copy. The shared map just has no durable home (it is hosted implicitly by
|
||||
whichever occurrence exists), so it does not survive to a new type.
|
||||
- IFC4 ADD2 TC1 `IfcShapeRepresentation`: identifier **`Reference`** = "3D
|
||||
representation that is **not part of the Body representation** ... used, e.g., for
|
||||
opening geometries ... excluded from an implicit Boolean operation." Schema-valid;
|
||||
`IfcTypeProduct` has no uniqueness rule on `RepresentationMaps` (only
|
||||
`ApplicableOccurrence`). So a `Reference` map can sit beside the `Body` map.
|
||||
- The geometry kernel selects an opening's geometry **by context, not by
|
||||
`RepresentationIdentifier`** (`mapping::representation_of`, `ifcgeom/mapping/mapping.cpp`).
|
||||
So a `Reference`-identified opening in the Body context still booleans correctly.
|
||||
Nothing in Bonsai reads `"Reference"` to *skip* applying an opening.
|
||||
- Caveat: IFC has no type-level void (`IfcRelVoidsElement` is occurrence-only). The
|
||||
"opening template on type" is therefore a Bonsai convention using a spec-valid
|
||||
identifier; other tools see a harmless extra `Reference` rep they ignore. The
|
||||
regeneration smarts are Bonsai-only by necessity.
|
||||
|
||||
## Design
|
||||
|
||||
Store the shared opening body on the **type** as a `Reference` representation map.
|
||||
Because `bim.duplicate_type` (`tool.Root.copy_representation`) and
|
||||
`append_type_product` both copy a type's `RepresentationMaps`, the template survives
|
||||
both. Occurrence openings map over the same map, so editing a void rewrites the
|
||||
shared map = updates the type template in one stroke (no separate write-back needed).
|
||||
|
||||
`map_type_representations` must skip `Reference` maps so the window/door occurrence
|
||||
does not receive the opening shape as its own Body (the kernel would otherwise pick
|
||||
arbitrarily between the real Body and the opening rep). The skip is both required and
|
||||
spec-endorsed ("not part of the Body representation").
|
||||
|
||||
### Body-context coexistence (Option A)
|
||||
|
||||
The template lives in the **Body** subcontext (required: the instance opening that maps
|
||||
over it must resolve in Body context for the geometry kernel to subtract it). So the
|
||||
type holds two reps in one context: the `Body` window body and the `Reference` opening
|
||||
template. Per IFC, `Reference` is a *RepresentationIdentifier value used within the Body
|
||||
context*, not a separate context - so we keep it there and disambiguate elsewhere:
|
||||
|
||||
- The representations panel now shows `RepresentationIdentifier` as its own column
|
||||
(`geometry/data.py`, `geometry/ui.py`) so the two Body-context reps are
|
||||
distinguishable (`Model | Body | MODEL_VIEW | Reference | Tessellation`). The panel
|
||||
column previously read "Body" because it shows `ContextOfItems.ContextIdentifier`,
|
||||
not the representation's identifier.
|
||||
- `Geometry.reimport_element_representations` type branch now renders the requested
|
||||
`base_representation` instead of `get_representation(element, context)`, which matched
|
||||
only by context and returned the window body when switching to the `Reference` rep.
|
||||
This is what makes "switch to the Reference row" actually show the void on the type.
|
||||
|
||||
### Precedence in `generate_opening_from_filling`
|
||||
type `Reference` template → (existing sibling occurrence, checked by callers) →
|
||||
type `Profile` extrusion → bbox extrusion.
|
||||
|
||||
### Type switching (assign_type)
|
||||
|
||||
On `type.assign_type` the opening is rebuilt to reflect the **assigned** type's void.
|
||||
Two listeners in `model/handler.py`:
|
||||
|
||||
- **pre** `Bonsai.Opening.PreserveOnTypeChange` → `preserve_opening_on_type_change`:
|
||||
before the filling moves to the new type, `promote_opening_to_type(old_type)` anchors
|
||||
the old type's custom void as a template, so it isn't lost when (possibly the last)
|
||||
occurrence is regenerated. Idempotent; custom voids only.
|
||||
- **post** `Bonsai.Opening.RegenerateFromType` → `regenerate_from_type` →
|
||||
`_regenerate_from_type`: rebuilds from the new type's template / sibling / extrusion.
|
||||
The old PR1 "preserve custom" guard was **removed** here — it kept the previous type's
|
||||
void on a switch (wrong), and the template now makes preservation unnecessary.
|
||||
|
||||
NOTE: upstream `v0.8.0` landed `assign_type` changes + new `test_assign_type_*` tests
|
||||
(merged under this branch's base). The listeners ride on top of that — re-test the
|
||||
switch/edit round-trips against the new `assign_type`.
|
||||
|
||||
### Write-back on void edit
|
||||
|
||||
Editing an occurrence's void writes the new geometry back to the type's `Reference`
|
||||
template via `update_type_template_from_opening` (creates the template if absent), then
|
||||
**re-maps every occurrence's opening onto the template** and reloads the affected host walls
|
||||
(`switch_representation`) so they re-boolean. The re-map (`_remap_opening_to_template`) is the
|
||||
key part: an earlier version only re-pointed a *pre-existing* shared map, so siblings whose
|
||||
openings were **independent** (their own `IfcRepresentationMap`, never sharing the template)
|
||||
didn't follow — the common real-world case. Now they do. Hooked at both commit paths:
|
||||
`UpdateRepresentation._execute` (the `edited_objs` path) and
|
||||
`OverrideModeSetObject` after `edit_representation_item` (the in-place item edit). The
|
||||
older `edit_openings`/`is_edited` path also calls it. `set_type_opening_representation`
|
||||
has replace semantics (one `Reference` map per type).
|
||||
|
||||
### Preserving adjusted extrusions (duplicate_type)
|
||||
|
||||
`is_opening_representation_custom` only flags *non-extrusion* geometry (tessellation, brep,
|
||||
CSG) as worth preserving — a proxy for "not regenerable". That mis-classifies a *manually
|
||||
adjusted* extrusion, which is still an `IfcExtrudedAreaSolid`, so a hand-tweaked extrusion
|
||||
opening was reset to the default on `duplicate_type`.
|
||||
|
||||
`promote_opening_to_type` now gates on `should_preserve_opening` = custom **or**
|
||||
`_is_adjusted_extrusion`. The latter generates the default (`generate_opening_from_filling`,
|
||||
which yields the default since no template exists at promote time) *transiently*, compares the
|
||||
two bodies' axis-aligned bounding boxes (1 mm tolerance) via the geom engine, then removes the
|
||||
temporary default. Divergence ⇒ the extrusion was adjusted ⇒ promote it; a plain default
|
||||
matches ⇒ left regenerable (not frozen — see the "freeze" discussion). Scoped to the duplicate
|
||||
path so the generate-and-compare stays out of the hot predicate. Limitation: bbox comparison
|
||||
misses a shape change that preserves the bbox (upgrade to a vertex-set compare if needed).
|
||||
|
||||
## Status — implemented (manually verified in Blender)
|
||||
|
||||
- core `map_type_representations.py`: skip `Reference` maps.
|
||||
- `model/opening.py`: `get_/set_type_opening_representation`, `promote_opening_to_type`,
|
||||
`update_type_template_from_opening` (+ `_remap_opening_to_template`),
|
||||
`preserve_opening_on_type_change`, `should_preserve_opening` (+ `_is_adjusted_extrusion`,
|
||||
`_representation_bbox`); `generate_opening_from_filling` consults the template; PR1 guard
|
||||
removed from `_regenerate_from_type`.
|
||||
- `model/handler.py`: pre + post assign_type listeners.
|
||||
- `type/operator.py` `DuplicateType`: promote before copy.
|
||||
- `project/operator.py` `AppendLibraryElement`: `harvest_opening_template`.
|
||||
- `geometry/operator.py`: write-back hooks in `UpdateRepresentation` and
|
||||
`OverrideModeSetObject`; `reimport_element_representations` renders the requested rep.
|
||||
- `geometry/data.py` + `geometry/ui.py`: `RepresentationIdentifier` column + headers.
|
||||
|
||||
Branch `opening-template-on-type` (#8200): initial feature commit + the #7916 build-conflict
|
||||
ancestry-merge + void-propagation-to-all-occurrences + adjusted-extrusion preservation. The
|
||||
`docs/dev-notes/` convention itself lives on the stacked branch `dev-notes-system` (#8201).
|
||||
|
||||
Still **deferred:** explicit "Apply/Reset to type" operators + a "diverges from type"
|
||||
indicator; import never auto-writes back. `update_simple_openings` still keeps its
|
||||
`is_opening_representation_custom` guard (array propagation, same type — left as-is).
|
||||
|
||||
## Things to test / verify
|
||||
|
||||
- Duplicated/appended type's new occurrence gets the faceset void and it **cuts** the
|
||||
wall (kernel selects opening geom by context, so a `Reference`-id rep still booleans).
|
||||
- `harvest_opening_template` cross-file `file.add`: no duplicate
|
||||
`IfcGeometricRepresentationContext` left behind; units (kernel doesn't rescale rep
|
||||
coords — same assumption as `append_asset`).
|
||||
- Switch X→Y→X round-trip restores each type's void; switching to a plain (template-less)
|
||||
type gives its default extrusion, not the previous faceset.
|
||||
- Edit a void → type's `Reference` row updates; **all** occurrences follow (including ones
|
||||
that had independent openings) and their host walls re-boolean; survives duplicate.
|
||||
- `duplicate_type` on a type whose extrusion opening was **manually adjusted** → Type B keeps
|
||||
the adjusted extrusion; a type with a plain/default extrusion stays regenerable (not frozen).
|
||||
- Three write-back hooks are intentional (different commit paths) — candidate for
|
||||
consolidation in review.
|
||||
- Re-test against upstream's new `assign_type` (see NOTE under "Type switching").
|
||||
+3
-8
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/python
|
||||
# /// script
|
||||
# ///
|
||||
###############################################################################
|
||||
# #
|
||||
# This file is part of IfcOpenShell. #
|
||||
@@ -124,16 +126,9 @@ ssl._create_default_https_context = ssl._create_unverified_context
|
||||
import time
|
||||
from collections.abc import Generator, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Literal, Union
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
try:
|
||||
from typing import Literal, Union
|
||||
except:
|
||||
# python 3.6 compatibility for rocky 8
|
||||
from typing import Union
|
||||
|
||||
from typing_extensions import Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
ch = logging.StreamHandler()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# /// script
|
||||
# ///
|
||||
"""
|
||||
Cache built dependencies for builds.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
+4
-7
@@ -3,9 +3,9 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.9",
|
||||
"ruff==0.15.12",
|
||||
"poethepoet",
|
||||
"ty==0.0.29",
|
||||
"ty==0.0.32",
|
||||
"gersemi==0.26.1",
|
||||
]
|
||||
|
||||
@@ -215,10 +215,7 @@ exclude = [
|
||||
|
||||
[tool.poe.tasks]
|
||||
|
||||
ruff-main = "ruff check --extend-exclude nix/build-all.py"
|
||||
# It's actually Python 3.6, but ruff only supports 3.7+, but it should do.
|
||||
ruff-old = "ruff check nix/build-all.py --target-version py37"
|
||||
ruff.sequence = ["ruff-main", "ruff-old"]
|
||||
ruff = "ruff check"
|
||||
|
||||
black = "black ."
|
||||
|
||||
@@ -238,7 +235,7 @@ ty-venv-ios.sequence = [
|
||||
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
|
||||
]
|
||||
|
||||
format.sequence = ["black", "ruff-main", "ruff-old"]
|
||||
format.sequence = ["black", "ruff"]
|
||||
|
||||
cmake-format = "gersemi . --in-place"
|
||||
|
||||
|
||||
+12
-4
@@ -17,8 +17,8 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
SHELL := sh
|
||||
PYTHON:=python3.11
|
||||
PIP:=pip3.11
|
||||
PYTHON:=python3
|
||||
PIP:=pip3
|
||||
PATCH:=patch
|
||||
SED:=sed -i
|
||||
VENV_ACTIVATE:=bin/activate
|
||||
@@ -106,7 +106,7 @@ endif
|
||||
endif # def PLATFORM
|
||||
|
||||
# Current build commit hash.
|
||||
OLD:=1c5b825
|
||||
OLD:=3e7b739
|
||||
.PHONY: bump
|
||||
bump:
|
||||
ifndef NEW
|
||||
@@ -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,7 @@ 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
|
||||
|
||||
try:
|
||||
from bonsai.translations import translations_dict
|
||||
@@ -88,6 +90,7 @@ modules = {
|
||||
"web": None,
|
||||
"light": None,
|
||||
"alignment": None,
|
||||
"clip_box": None,
|
||||
# Uncomment this line to enable loading of the demo module. Happy hacking!
|
||||
# The name "demo" must correlate to a folder name in `bim/module/`.
|
||||
# "demo": None,
|
||||
@@ -157,9 +160,6 @@ classes = [
|
||||
ui.BIM_UL_tab_visibilities,
|
||||
ui.BIM_UL_panel_visibilities,
|
||||
ui.DocPreferences,
|
||||
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesStair, # Register before GizmoPreferences
|
||||
ui.GizmoPreferences,
|
||||
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
|
||||
# Tabs panel
|
||||
@@ -268,6 +268,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 +327,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.
|
||||
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
|
||||
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -33,5 +33,7 @@ DATA;
|
||||
#26=IFCSIMPLEPROPERTYTEMPLATE('2iwERDOW55Pf4hCbuFRe1Q',$,'FillMode','Method to fill areas seen in projection',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#27=IFCSIMPLEPROPERTYTEMPLATE('1YF$qLzBzF19Io8aB2N8cE',$,'CutMode','Method for cutting geometry',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -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,16 +32,32 @@ 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.array import (
|
||||
ArrayPreviewDecorator,
|
||||
ArraySelectionHighlightDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
BendPreviewDecorator,
|
||||
BoundingBoxDecorator,
|
||||
DoorSwingReadonlyDecorator,
|
||||
MEPSegmentExtendPreviewDecorator,
|
||||
MEPSystemPathDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
WallSystemPathDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -108,19 +125,13 @@ def active_object_callback():
|
||||
|
||||
|
||||
def update_bim_tool_props():
|
||||
"""update BIM Tools props (such as extrusion_depth, length and x_angle) when active object changes"""
|
||||
obj = bpy.context.active_object
|
||||
|
||||
# bunch of checks to see if we're in a valid state
|
||||
if not obj:
|
||||
return
|
||||
mode = bpy.context.mode
|
||||
current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
"""Selection-driven BIM Tool sync: re-target user-intent enums
|
||||
(ifc_class, relating_type_id) AND refresh header values
|
||||
(extrusion_depth, length, x_angle) for the new active object."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
aprops = tool.Drawing.get_annotation_props()
|
||||
@@ -133,18 +144,85 @@ 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
|
||||
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def refresh_bim_tool_headers():
|
||||
"""Push the active IFC entity's current header float values
|
||||
(extrusion_depth, length, x_angle) into ``BIMModelProperties``.
|
||||
Enum-safe: never writes user-intent enum slots, which are owned by
|
||||
the selection callback."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
if current_tool.idname not in tool.Blender.get_property_header_tools():
|
||||
return
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def _resolve_bim_tool_context():
|
||||
"""Return ``(obj, current_tool, element)`` when an active BIM workspace
|
||||
tool sees a resolvable IFC element; ``None`` otherwise. Defensive
|
||||
against stripped operator contexts — a missing ``active_object`` /
|
||||
``mode`` / ``workspace`` short-circuits to ``None`` instead of raising."""
|
||||
obj = tool.Blender.get_active_object()
|
||||
if not obj:
|
||||
return None
|
||||
mode = getattr(bpy.context, "mode", None)
|
||||
workspace = getattr(bpy.context, "workspace", None)
|
||||
if mode is None or workspace is None:
|
||||
return None
|
||||
current_tool = workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return None
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return None
|
||||
return obj, current_tool, element
|
||||
|
||||
|
||||
def _read_headers_into_props(obj, element):
|
||||
"""Populate ``BIMModelProperties`` header values from the active
|
||||
object's IFC extrusion. Enum-safe: writes only header floats, never
|
||||
user-intent enum slots, so it is safe to call on the post-commit hook."""
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
return
|
||||
@@ -162,10 +240,13 @@ def update_bim_tool_props():
|
||||
if not AuthoringData.is_loaded:
|
||||
AuthoringData.load()
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
x_angle = get_x_angle(extrusion)
|
||||
axis = tool.Model.get_wall_axis(obj)["reference"]
|
||||
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 +437,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, load-transient parametric state, and the
|
||||
multi-instance lock probe."""
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -368,6 +451,23 @@ def load_post(scene):
|
||||
ifcopenshell.api.owner.settings.get_application = get_application
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
tool.Parametric.on_load_post(scene)
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
props.has_blend_warning = True
|
||||
|
||||
# Probe the H5 cooked-geometry cache so the multi-instance warning surfaces
|
||||
# right after .blend load. Without this, the lock is only detected when a
|
||||
# mutation triggers ``clear_cache`` — by which time the user has already
|
||||
# made changes that may now conflict with the other Blender instance.
|
||||
if tool.Ifc.get():
|
||||
get_cache_or_detect_lock()
|
||||
|
||||
|
||||
def _apply_user_preferences() -> None:
|
||||
"""User-preference-driven UI setup: toolbar, BIM workspace, viewport shading
|
||||
subscription, scene-panel hijack, tab layout, snap defaults."""
|
||||
preferences = tool.Blender.get_addon_preferences()
|
||||
if not preferences.should_setup_toolbar:
|
||||
tool.Blender.unregister_toolbar()
|
||||
@@ -391,11 +491,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 +515,62 @@ 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)
|
||||
MEPSystemPathDecorator.uninstall()
|
||||
WallSystemPathDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
BendPreviewDecorator.uninstall()
|
||||
MEPSegmentExtendPreviewDecorator.uninstall()
|
||||
WallGizmoPreviewDecorator.uninstall()
|
||||
DoorSwingReadonlyDecorator.uninstall()
|
||||
ArrayPreviewDecorator.uninstall()
|
||||
ArraySelectionHighlightDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
try:
|
||||
if georeference_props.should_visualise:
|
||||
GeoreferenceDecorator.install(bpy.context)
|
||||
if aggregate_props.aggregate_decorator:
|
||||
AggregateDecorator.install(bpy.context)
|
||||
if nest_props.nest_decorator:
|
||||
NestDecorator.install(bpy.context)
|
||||
if model_props.show_wall_axis:
|
||||
WallAxisDecorator.install(bpy.context)
|
||||
if model_props.show_slab_direction:
|
||||
SlabDirectionDecorator.install(bpy.context)
|
||||
if model_props.show_paths:
|
||||
MEPSystemPathDecorator.install(bpy.context)
|
||||
WallSystemPathDecorator.install(bpy.context)
|
||||
if model_props.show_bounding_box:
|
||||
BoundingBoxDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
|
||||
# wall_fillet.is_active, so installation has no cost when no preview
|
||||
# is open. No corresponding addon-preference toggle.
|
||||
WallFilletPreviewDecorator.install(bpy.context)
|
||||
# Always-installed siblings of WallFilletPreviewDecorator: each
|
||||
# self-polls on its own scene.BIMPreviewProperties subgroup or on
|
||||
# selection + hover gizmo state — zero cost when nothing is active.
|
||||
BendPreviewDecorator.install(bpy.context)
|
||||
MEPSegmentExtendPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw_lines() self-polls on selection + hover state
|
||||
# for join / extend-to-wall / cursor-extend / cursor-split previews.
|
||||
# Free when no preview-eligible state is active.
|
||||
WallGizmoPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on active object + IfcDoor +
|
||||
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
|
||||
# nothing eligible is selected.
|
||||
DoorSwingReadonlyDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on the active object's array
|
||||
# family membership, so installation has no cost when no array
|
||||
# element is selected.
|
||||
ArraySelectionHighlightDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on props.is_editing — only
|
||||
# paints during an active array edit lifecycle.
|
||||
ArrayPreviewDecorator.install(bpy.context)
|
||||
finally:
|
||||
install_decorator_cache_handlers()
|
||||
|
||||
if 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(operator)
|
||||
|
||||
if method == "MODAL":
|
||||
cls.modal_in_progress = False
|
||||
@@ -527,6 +566,19 @@ class IfcStore:
|
||||
result = getattr(operator, "_modal")(context, event)
|
||||
except:
|
||||
bonsai.last_error = traceback.format_exc()
|
||||
# An operator that mutated IFC then raised leaves the IFC graph captured
|
||||
# by the transaction but the Blender side stale. Blender does not push an
|
||||
# undo step for a raised operator (mirror of the CANCELLED-modal gap
|
||||
# handled below), so we push one here so Ctrl+Z actually rewinds the
|
||||
# partial mutation, then surface the recovery path to the user.
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file and ifc_file.transaction and ifc_file.transaction.operations:
|
||||
bpy.ops.ed.undo_push(message=f"Recover {operator.bl_idname}")
|
||||
operator.report(
|
||||
{"WARNING"},
|
||||
"Operation partially completed (IFC changed, Blender state may be stale). "
|
||||
"Press Ctrl+Z to restore the previous state.",
|
||||
)
|
||||
# Try to ensure undo will work since Blender undo does work in case of errors.
|
||||
# As error come unexpectedly, it's important that user might have a chance to save the file
|
||||
# before they got the error and not to lose the work they've done.
|
||||
|
||||
@@ -223,6 +223,7 @@ class IfcImporter:
|
||||
self.elements: set[ifcopenshell.entity_instance] = set()
|
||||
self.annotations: set[ifcopenshell.entity_instance] = set()
|
||||
self.gross_elements: set[ifcopenshell.entity_instance] = set()
|
||||
self.broken_arrays: set[ifcopenshell.entity_instance] = set()
|
||||
self.element_types: set[ifcopenshell.entity_instance] = set()
|
||||
self.spatial_elements: set[ifcopenshell.entity_instance] = set()
|
||||
self.meshes: dict[str, OBJECT_DATA_TYPE] = {}
|
||||
@@ -1219,8 +1220,18 @@ class IfcImporter:
|
||||
if element not in elements_to_import:
|
||||
continue
|
||||
for i in range(len(data)):
|
||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
tool.Array.set_children_lock_state(element, i, True)
|
||||
tool.Array.constrain_children_to_parent(element)
|
||||
for layer in data:
|
||||
for child_guid in layer.get("children", ()):
|
||||
try:
|
||||
self.file.by_guid(child_guid)
|
||||
except RuntimeError:
|
||||
print(
|
||||
f"setup_arrays: array parent {element.GlobalId} references missing "
|
||||
f"child GUID {child_guid!r}."
|
||||
)
|
||||
self.broken_arrays.add(element)
|
||||
|
||||
def update_linked_aggregates(self):
|
||||
# TODO Remove this after a while. See commit 17d6b8a
|
||||
|
||||
@@ -20,7 +20,6 @@ import blf
|
||||
import bpy
|
||||
import gpu
|
||||
import ifcopenshell.util.element
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras import view3d_utils
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
@@ -28,12 +27,6 @@ from mathutils import Vector
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
def create_bounding_box(objs):
|
||||
# Initialize the bounding box coordinates
|
||||
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
|
||||
@@ -79,26 +72,8 @@ def create_bounding_box(objs):
|
||||
return indices, edges
|
||||
|
||||
|
||||
class AggregateDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_aggregate, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
class AggregateDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_aggregate"
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
@@ -153,14 +128,6 @@ class AggregateDecorator:
|
||||
shader.uniform_float("u_Scale", 25)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_aggregate(self, context):
|
||||
props = tool.Aggregate.get_aggregate_props()
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -191,12 +158,13 @@ class AggregateDecorator:
|
||||
aggregates.append(obj)
|
||||
continue
|
||||
|
||||
aggregate = None
|
||||
aggregates_list = tool.Aggregate.get_aggregates_recursively(element)
|
||||
if props.in_aggregate_mode and props.editing_aggregate:
|
||||
index = aggregates_list.index(tool.Ifc.get_entity(props.editing_aggregate))
|
||||
if index > 0:
|
||||
aggregate = aggregates_list[index - 1]
|
||||
else:
|
||||
elif aggregates_list:
|
||||
aggregate = aggregates_list[-1]
|
||||
if aggregate:
|
||||
aggregates.append(tool.Ifc.get_object(aggregate))
|
||||
@@ -225,39 +193,11 @@ class AggregateDecorator:
|
||||
self.draw_custom_batch(line, decorator_color_unselected)
|
||||
|
||||
|
||||
class AggregateModeDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_aggregate_name, (context,), "WINDOW", "POST_PIXEL")
|
||||
)
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_aggregate_empty, (context,), "WINDOW", "POST_VIEW")
|
||||
)
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
class AggregateModeDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_aggregate_name", "POST_PIXEL"),
|
||||
("draw_aggregate_empty", "POST_VIEW"),
|
||||
)
|
||||
|
||||
def draw_aggregate_name(self, context):
|
||||
if context.mode == "EDIT_MESH":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -56,11 +56,6 @@ class BoundaryDecorator:
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
special_elements_color = self.addon_prefs.decorator_color_special
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
gpu.state.point_size_set(6)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
@@ -109,7 +104,11 @@ class BoundaryDecorator:
|
||||
|
||||
if unselected_edges:
|
||||
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
|
||||
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
|
||||
self.draw_batch(
|
||||
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
|
||||
)
|
||||
if selected_edges:
|
||||
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
|
||||
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
|
||||
self.draw_batch(
|
||||
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
|
||||
)
|
||||
|
||||
@@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator):
|
||||
class CadOffset(bpy.types.Operator):
|
||||
bl_idname = "bim.cad_offset"
|
||||
bl_label = "CAD Offset"
|
||||
bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
|
||||
bl_description = (
|
||||
"Offset selected mesh geometry at provided distance, based on the current viewport angle. "
|
||||
"Creates a copy by default, or moves the existing edges if Copy is disabled."
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
|
||||
copy: bpy.props.BoolProperty(
|
||||
name="Copy",
|
||||
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
|
||||
default=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator):
|
||||
rotation = Matrix.Rotation(pi / 2, 2, "Z")
|
||||
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
|
||||
|
||||
# When not copying, the offset positions are gathered here and applied to
|
||||
# the existing verts only after all loops are processed, so that the
|
||||
# original coordinates are still available while computing offsets.
|
||||
moved_verts = []
|
||||
|
||||
# Create loops from edges
|
||||
loop_edges = set(edges)
|
||||
loops = []
|
||||
@@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator):
|
||||
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
|
||||
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
|
||||
new_vert = v1.co + offset
|
||||
new_verts.append(bm.verts.new(new_vert))
|
||||
else:
|
||||
normal = (normals[0] * self.distance).to_3d()
|
||||
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
|
||||
new_vert = v1.co + offset
|
||||
|
||||
if self.copy:
|
||||
new_verts.append(bm.verts.new(new_vert))
|
||||
else:
|
||||
moved_verts.append((v1, new_vert))
|
||||
|
||||
processed_verts.add(v1.index)
|
||||
|
||||
@@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator):
|
||||
|
||||
v1 = v2
|
||||
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
if is_closed:
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
if self.copy:
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
if is_closed:
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
|
||||
# Move the existing edges to the offset location.
|
||||
for vert, new_co in moved_verts:
|
||||
vert.co = new_co
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
|
||||
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
|
||||
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
|
||||
copy: bpy.props.BoolProperty(
|
||||
name="Copy",
|
||||
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
|
||||
default=True,
|
||||
)
|
||||
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
|
||||
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
|
||||
gable_roof_edge_angle: bpy.props.FloatProperty(
|
||||
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: int
|
||||
radius: float
|
||||
distance: float
|
||||
copy: bool
|
||||
x: float
|
||||
y: float
|
||||
gable_roof_edge_angle: float
|
||||
|
||||
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
|
||||
elif self.hotkey == "S_O":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "distance")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "copy")
|
||||
|
||||
elif self.hotkey == "S_R":
|
||||
if tool.Geometry.is_profile_object_active():
|
||||
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
|
||||
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
|
||||
|
||||
def hotkey_S_O(self):
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance)
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
|
||||
|
||||
def hotkey_S_Q(self):
|
||||
obj = bpy.context.active_object
|
||||
|
||||
@@ -18,43 +18,17 @@
|
||||
|
||||
import blf
|
||||
import gpu
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class ClashDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
class ClashDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw_geometry", "POST_VIEW"),
|
||||
)
|
||||
|
||||
def draw_text(self, context):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import face_quad, gizmos, operator, prop, ui
|
||||
|
||||
classes = (
|
||||
operator.BIM_OT_add_clip_box,
|
||||
operator.BIM_OT_add_clip_box_for_source,
|
||||
operator.BIM_OT_align_view_to_clip_face,
|
||||
operator.BIM_OT_duplicate_clip_box,
|
||||
operator.BIM_OT_remove_clip_box,
|
||||
operator.BIM_OT_set_active_clip_box,
|
||||
operator.BIM_OT_toggle_clip_box_enabled,
|
||||
prop.BIMClipBoxProperties,
|
||||
prop.BIMSceneClipBoxProperties,
|
||||
face_quad.BIM_GT_box_face_quad,
|
||||
face_quad.BIM_GT_box_face_outline,
|
||||
gizmos.OBJECT_GGT_bim_clip_box,
|
||||
ui.BIM_MT_clip_box_add_for_source,
|
||||
ui.BIM_MT_clip_box_info,
|
||||
ui.BIM_MT_clip_box_settings,
|
||||
ui.BIM_UL_clip_box,
|
||||
ui.BIM_PT_clip_box,
|
||||
)
|
||||
|
||||
|
||||
@persistent
|
||||
def _on_depsgraph_update(scene, depsgraph):
|
||||
tool.ClipBox.on_depsgraph_update(scene, depsgraph)
|
||||
tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph)
|
||||
|
||||
|
||||
@persistent
|
||||
def _on_load_pre(filepath):
|
||||
# Tear down any in-flight clip-box timers before Blender frees the
|
||||
# WM / screens / areas / regions for the loading file. A refresh timer
|
||||
# that survives the teardown fires against the new file's freshly-
|
||||
# allocated regions before their GPU state is wired, CTD-ing inside
|
||||
# GPU_matrix_ortho_set. The gate also blocks the depsgraph IFC-reload
|
||||
# branch and is held closed until on_pre_view fires for the first time
|
||||
# on the new file (first paint = GPU contexts wired).
|
||||
tool.ClipBox._file_loading = True
|
||||
tool.ClipBox._post_load_paint_pending = True
|
||||
tool.ClipBox._cancel_pending_refresh()
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
|
||||
@persistent
|
||||
def _on_load_post(filepath):
|
||||
# The _file_loading gate is NOT cleared here: load_post fires before
|
||||
# the new file's first paint, so GPU contexts may still be uninitialised.
|
||||
# on_pre_view consumes _post_load_paint_pending to open the gate at the
|
||||
# safe moment and kick the post-load re-arm.
|
||||
# Restore the per-scene clip-box list from the project's BBIM_ClipBoxes
|
||||
# pset. Runs after the standard load_post that creates Blender objects.
|
||||
tool.ClipBox._last_seen_object_matrices.clear()
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
|
||||
|
||||
_draw_handler_pre = None
|
||||
_draw_handler_post = None
|
||||
|
||||
|
||||
def register():
|
||||
global _draw_handler_pre, _draw_handler_post
|
||||
bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties)
|
||||
bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties)
|
||||
tool.ClipBox.reset_ownership()
|
||||
if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update)
|
||||
if _on_load_pre not in bpy.app.handlers.load_pre:
|
||||
bpy.app.handlers.load_pre.append(_on_load_pre)
|
||||
if _on_load_post not in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.append(_on_load_post)
|
||||
if _draw_handler_pre is None:
|
||||
_draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW")
|
||||
if _draw_handler_post is None:
|
||||
_draw_handler_post = bpy.types.SpaceView3D.draw_handler_add(
|
||||
tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW"
|
||||
)
|
||||
|
||||
|
||||
def unregister():
|
||||
global _draw_handler_pre, _draw_handler_post
|
||||
if _draw_handler_post is not None:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
_draw_handler_post = None
|
||||
if _draw_handler_pre is not None:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
_draw_handler_pre = None
|
||||
if _on_load_post in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.remove(_on_load_post)
|
||||
if _on_load_pre in bpy.app.handlers.load_pre:
|
||||
bpy.app.handlers.load_pre.remove(_on_load_pre)
|
||||
if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
|
||||
tool.ClipBox._cancel_pending_refresh()
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
tool.ClipBox._last_seen_object_matrices.clear()
|
||||
tool.ClipBox.clear_clip_planes()
|
||||
del bpy.types.Object.BIMClipBoxProperties
|
||||
del bpy.types.Scene.BIMSceneClipBoxProperties
|
||||
@@ -0,0 +1,212 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""EnumProperty ``items=`` callbacks for the source-based clip-box picker.
|
||||
|
||||
Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is
|
||||
an IFC entity id stringified for entity-driven kinds, an IFC class name for
|
||||
``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator
|
||||
turns the picked id into a ``matrix_world`` via the source-preset helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
EnumItems = list[tuple[str, str, str]]
|
||||
|
||||
# Module-level cache. Blender's EnumProperty stores raw char pointers from the
|
||||
# tuples a callback returns, so the Python strings must outlive the draw call.
|
||||
# Stashing the latest result per kind keeps them alive across callback firings.
|
||||
_items_cache: dict[str, EnumItems] = {}
|
||||
|
||||
# Sentinel id used for the "no options available" placeholder. The operator
|
||||
# treats this as an invalid pick and surfaces an ERROR.
|
||||
NO_OPTIONS_ID = "__none__"
|
||||
|
||||
|
||||
def _cache(kind: str, items: EnumItems) -> EnumItems:
|
||||
_items_cache[kind] = items
|
||||
return items
|
||||
|
||||
|
||||
def _no_options(label: str) -> EnumItems:
|
||||
# Blender refuses to draw an EnumProperty with zero entries — show a
|
||||
# placeholder so the dialog renders and the user sees the empty state.
|
||||
return [(NO_OPTIONS_ID, label, "")]
|
||||
|
||||
|
||||
def _label(entity, ifc_class: str | None = None) -> str:
|
||||
name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
|
||||
return f"{ifc_class}: {name}" if ifc_class else name
|
||||
|
||||
|
||||
def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems:
|
||||
"""Shared shape for the IFC-driven enum callbacks.
|
||||
|
||||
Returns the no-IFC placeholder if no file is loaded, then runs
|
||||
``build_fn(ifc_file)``, sorts the result alphabetically by label, and
|
||||
returns the empty-result placeholder if nothing matched. The output is
|
||||
always routed through the module cache.
|
||||
"""
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache(kind, _no_options("No IFC loaded"))
|
||||
items = build_fn(ifc)
|
||||
items.sort(key=lambda t: t[1].lower())
|
||||
if not items:
|
||||
return _cache(kind, _no_options(empty_label))
|
||||
return _cache(kind, items)
|
||||
|
||||
|
||||
# Top-down spatial hierarchy so the picker reads in the order an architect
|
||||
# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded
|
||||
# — spaces are typically empty volumes used for room metadata, so clipping to
|
||||
# one rarely matches the user intent of "show me what's in this container".
|
||||
SPATIAL_CLASSES: tuple[str, ...] = (
|
||||
"IfcProject",
|
||||
"IfcSite",
|
||||
"IfcBuilding",
|
||||
"IfcBuildingStorey",
|
||||
)
|
||||
|
||||
|
||||
def spatial_items(self, context) -> EnumItems:
|
||||
# Special-case: per-class sort within the hierarchy order rather than a
|
||||
# flat alphabetical sort, so the dropdown reads project → site → building.
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache("SPATIAL", _no_options("No IFC loaded"))
|
||||
items: EnumItems = []
|
||||
for ifc_class in SPATIAL_CLASSES:
|
||||
try:
|
||||
entities = ifc.by_type(ifc_class, include_subtypes=False)
|
||||
except RuntimeError:
|
||||
continue
|
||||
for entity in sorted(entities, key=lambda e: (e.Name or "").lower()):
|
||||
items.append((str(entity.id()), _label(entity, ifc_class), ""))
|
||||
if not items:
|
||||
return _cache("SPATIAL", _no_options("No spatial containers"))
|
||||
return _cache("SPATIAL", items)
|
||||
|
||||
|
||||
def class_items(self, context) -> EnumItems:
|
||||
# Special-case: the picker value IS the IFC class name, not an entity id,
|
||||
# so the build shape differs from the other entity-driven callbacks.
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache("CLASS", _no_options("No IFC loaded"))
|
||||
# List only IFC classes ACTUALLY present in the file (not the whole
|
||||
# schema), so the user picks from classes that can produce a non-empty
|
||||
# clip volume. ``e.is_a()`` returns the most specific class per element.
|
||||
present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")})
|
||||
if not present:
|
||||
return _cache("CLASS", _no_options("No products"))
|
||||
return _cache("CLASS", [(cls, cls, "") for cls in present])
|
||||
|
||||
|
||||
def type_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"TYPE",
|
||||
"No types defined",
|
||||
lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")],
|
||||
)
|
||||
|
||||
|
||||
def material_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"MATERIAL",
|
||||
"No materials defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")],
|
||||
)
|
||||
|
||||
|
||||
def profile_items(self, context) -> EnumItems:
|
||||
# ProfileName is optional. Skip unnamed profiles — they can't be
|
||||
# meaningfully picked from a flat list.
|
||||
return _build_items(
|
||||
"PROFILE",
|
||||
"No named profiles",
|
||||
lambda ifc: [
|
||||
(str(e.id()), f"{e.is_a()}: {e.ProfileName}", "")
|
||||
for e in ifc.by_type("IfcProfileDef")
|
||||
if getattr(e, "ProfileName", None)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def drawing_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"DRAWING",
|
||||
"No drawings defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcAnnotation") if e.ObjectType == "DRAWING"],
|
||||
)
|
||||
|
||||
|
||||
# Display labels for each status value. The id strings on the left are the
|
||||
# canonical Pset_*Common.Status enum values accepted by Bonsai's status query.
|
||||
STATUS_LABELS: tuple[tuple[str, str], ...] = (
|
||||
("No Status", "No Status"),
|
||||
("NEW", "New"),
|
||||
("EXISTING", "Existing"),
|
||||
("DEMOLISH", "Demolish"),
|
||||
("TEMPORARY", "Temporary"),
|
||||
("OTHER", "Other"),
|
||||
("NOTKNOWN", "Not Known"),
|
||||
("UNSET", "Unset"),
|
||||
)
|
||||
|
||||
|
||||
def status_items(self, context) -> EnumItems:
|
||||
# Fixed enum; no IFC needed. Still routed through the cache to share the
|
||||
# same string-lifetime guarantee as the other callbacks.
|
||||
return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS])
|
||||
|
||||
|
||||
def system_items(self, context) -> EnumItems:
|
||||
# IfcStructuralAnalysisModel is a structural-grouping container, not a
|
||||
# distribution system — excluded to match Bonsai's other system pickers.
|
||||
return _build_items(
|
||||
"SYSTEM",
|
||||
"No systems defined",
|
||||
lambda ifc: [
|
||||
(str(e.id()), _label(e, e.is_a()), "")
|
||||
for e in ifc.by_type("IfcSystem")
|
||||
if not e.is_a("IfcStructuralAnalysisModel")
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def group_items(self, context) -> EnumItems:
|
||||
# include_subtypes=False so IfcSystem and IfcZone instances don't appear
|
||||
# under Group as well — those get their own picker entries.
|
||||
return _build_items(
|
||||
"GROUP",
|
||||
"No groups defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcGroup", include_subtypes=False)],
|
||||
)
|
||||
|
||||
|
||||
def zone_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"ZONE",
|
||||
"No zones defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")],
|
||||
)
|
||||
@@ -0,0 +1,879 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Generic face-quad resize gizmos for any axis-aligned local box.
|
||||
|
||||
This module contains the box-agnostic core of the interactive
|
||||
face-resize gizmos: two Gizmo classes (a near-invisible click target
|
||||
welded to each face, and a thin colored edge outline), a per-redraw
|
||||
orchestrator that places six of each on a box, and the pure one-sided
|
||||
resize arithmetic. None of it knows about IFC, clip boxes, or
|
||||
``BIMSceneClipBoxProperties`` — a future camera-view-box adapter can
|
||||
reuse the same classes and helpers.
|
||||
|
||||
Consumer contract — the adapter group must:
|
||||
|
||||
1. Create six ``BIM_GT_box_face_quad`` and six ``BIM_GT_box_face_outline``
|
||||
instances at ``setup()`` time, in :data:`FACE_ROUTES` order, and bind
|
||||
each quad's ``move_get_cb`` / ``move_set_cb`` to closures that read
|
||||
and mutate the box's host (e.g. an Empty's ``location`` / ``scale``).
|
||||
2. Call :func:`apply_face_quad_layout` from ``refresh()`` /
|
||||
``draw_prepare()`` with the box's local-frame ``bmin`` / ``bmax``,
|
||||
the host's ``matrix_world``, the OBB rotation as a 4x4
|
||||
(``Matrix.Identity(4)`` when the rotation rides in ``matrix_world``),
|
||||
and the current ``region`` / ``rv3d``.
|
||||
3. Implement ``_lock_for(active_gz)`` / ``_unlock_all()`` on the group
|
||||
for drag mutual exclusion; the quad's ``invoke`` / ``exit`` call them.
|
||||
|
||||
The resize arithmetic in :func:`compute_face_resize` is pure: feed it
|
||||
the modal scalar plus drag-start snapshots and it returns the host's
|
||||
new scale-on-axis and new origin location.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public iteration order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# (axis, is_max) pairs. The adapter group's ``setup()`` MUST create its
|
||||
# six face-quad gizmos in this order so positional indexing into the
|
||||
# layout helper stays correct.
|
||||
FACE_ROUTES: tuple[tuple[int, bool], ...] = (
|
||||
(0, False),
|
||||
(0, True),
|
||||
(1, False),
|
||||
(1, True),
|
||||
(2, False),
|
||||
(2, True),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public visual constants (adapter reads these in setup())
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Standard XYZ axis colors (Blender convention).
|
||||
AXIS_COLOR: dict[int, tuple[float, float, float]] = {
|
||||
0: (1.0, 0.2, 0.2),
|
||||
1: (0.2, 1.0, 0.2),
|
||||
2: (0.2, 0.4, 1.0),
|
||||
}
|
||||
|
||||
# Documented "selectable but unpainted" trick: the GPU still writes the
|
||||
# selection buffer at this alpha so clicks register, but no visible
|
||||
# pixels are produced.
|
||||
FACE_QUAD_ALPHA: float = 0.001
|
||||
|
||||
# Very faint hover tint — just enough to confirm "you're aiming at this
|
||||
# face" without painting visibly over geometry behind it.
|
||||
FACE_QUAD_ALPHA_HIGHLIGHT: float = 0.04
|
||||
|
||||
# Setup-time default for ``select_bias``; the layout helper overwrites
|
||||
# it per frame to the front-facing or halo value below. Kept below the
|
||||
# canonical arrow bias so a bailed frame can't let a front quad steal
|
||||
# clicks meant for a hidden control.
|
||||
FACE_QUAD_SELECT_BIAS: float = 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Unit quad in the local XY plane spanning [-0.5, 0.5]^2 at z=0. Two
|
||||
# CCW triangles viewed from +Z. matrix_basis stretches it onto the
|
||||
# face's perpendicular extents.
|
||||
_QUAD_TRIS: list[tuple[float, float, float]] = [
|
||||
(-0.5, -0.5, 0.0),
|
||||
(0.5, -0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(-0.5, -0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(-0.5, 0.5, 0.0),
|
||||
]
|
||||
|
||||
# Unit-quad outline as 4 line segments in the local XY plane at z=0.
|
||||
_QUAD_OUTLINE_LINES: list[tuple[float, float, float]] = [
|
||||
(-0.5, -0.5, 0.0),
|
||||
(0.5, -0.5, 0.0),
|
||||
(0.5, -0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(0.5, 0.5, 0.0),
|
||||
(-0.5, 0.5, 0.0),
|
||||
(-0.5, 0.5, 0.0),
|
||||
(-0.5, -0.5, 0.0),
|
||||
]
|
||||
|
||||
# Degenerate zero-area triangle for hidden back-facing quads with no
|
||||
# visible-adjacent neighbours (rare orientation). Blender tolerates
|
||||
# this; the gizmo is hidden anyway so nothing renders.
|
||||
_EMPTY_TRIS: list[tuple[float, float, float]] = [
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
]
|
||||
|
||||
# Rotates the gizmo's local +Z onto the outward face normal in the
|
||||
# box's local frame. Right-hand rotation around the named axis.
|
||||
_AXIS_ORIENT: dict[tuple[int, bool], Matrix] = {
|
||||
(0, False): Matrix.Rotation(-math.pi / 2, 4, "Y"),
|
||||
(0, True): Matrix.Rotation(math.pi / 2, 4, "Y"),
|
||||
(1, False): Matrix.Rotation(math.pi / 2, 4, "X"),
|
||||
(1, True): Matrix.Rotation(-math.pi / 2, 4, "X"),
|
||||
(2, False): Matrix.Rotation(math.pi, 4, "X"),
|
||||
(2, True): Matrix.Identity(4),
|
||||
}
|
||||
|
||||
# Per-face mapping from face-quad local axes to local box axes for the
|
||||
# perpendicular-extent scale. ``(w_axis, h_axis)`` — the box-local axis
|
||||
# indices the quad's local X and Y span after the orientation rotation.
|
||||
_QUAD_PERP_AXES: dict[tuple[int, bool], tuple[int, int]] = {
|
||||
(0, False): (2, 1),
|
||||
(0, True): (2, 1),
|
||||
(1, False): (0, 2),
|
||||
(1, True): (0, 2),
|
||||
(2, False): (0, 1),
|
||||
(2, True): (0, 1),
|
||||
}
|
||||
|
||||
# Front-facing quad sits ABOVE the halo strips so the cursor on the
|
||||
# visible face area always grabs the visible face, never accidentally
|
||||
# routes to a back-face halo strip in an adjacent screen region.
|
||||
_FACE_QUAD_FRONT_FACING_SELECT_BIAS: float = 1.5
|
||||
_FACE_QUAD_HALO_FRAME_SELECT_BIAS: float = 1.0
|
||||
|
||||
# Target halo-strip thickness in screen pixels. The world-space margin
|
||||
# is recomputed per frame so the rim stays a roughly constant on-screen
|
||||
# size regardless of viewport zoom.
|
||||
_FACE_QUAD_HALO_TARGET_PIXELS: float = 20.0
|
||||
|
||||
# Minimum world half-extent a face resize may shrink to. Stops a drag
|
||||
# from collapsing the host to zero or negative scale.
|
||||
_MIN_HALF_EXTENT: float = 1e-4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure predicates (testable without Blender)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Vec3 = tuple[float, float, float]
|
||||
|
||||
|
||||
def face_outward_axis_local(axis: int, is_max: bool) -> Vec3:
|
||||
"""Un-rotated outward face normal in the box's local AABB coords.
|
||||
|
||||
For ``(axis=0, is_max=True)`` returns ``(+1, 0, 0)``; for the −X
|
||||
face ``(-1, 0, 0)``; etc. The rotated world normal is obtained by
|
||||
applying the host's rotation and the OBB rotation:
|
||||
``mw_rot @ cage_rotation @ this``.
|
||||
"""
|
||||
sign = 1.0 if is_max else -1.0
|
||||
out = [0.0, 0.0, 0.0]
|
||||
out[axis] = sign
|
||||
return (out[0], out[1], out[2])
|
||||
|
||||
|
||||
def front_facing_face_mask(
|
||||
face_normals_world: Sequence[Vec3],
|
||||
view_dir_world: Vec3,
|
||||
eps: float = 1e-6,
|
||||
) -> tuple[bool, ...]:
|
||||
"""Which of the 6 box faces point toward the camera.
|
||||
|
||||
A face is front-facing iff its outward normal points AGAINST the
|
||||
view direction (``dot(normal, view_dir) < -eps``). The ``-eps``
|
||||
margin prevents flicker at grazing angles.
|
||||
|
||||
``face_normals_world`` must be in :data:`FACE_ROUTES` order; returns
|
||||
a 6-tuple of bool parallel to that order.
|
||||
"""
|
||||
if len(face_normals_world) != 6:
|
||||
msg = f"expected 6 face normals, got {len(face_normals_world)}"
|
||||
raise ValueError(msg)
|
||||
vx, vy, vz = view_dir_world
|
||||
return tuple((n[0] * vx + n[1] * vy + n[2] * vz) < -eps for n in face_normals_world)
|
||||
|
||||
|
||||
def view_axis_parallel_face_mask(
|
||||
face_normals_world: Sequence[Vec3],
|
||||
view_dir_world: Vec3,
|
||||
threshold: float = 0.95,
|
||||
) -> tuple[bool, ...]:
|
||||
"""Which faces have normals (anti-)parallel to the view direction.
|
||||
|
||||
True iff ``abs(dot(normal, view_dir)) >= threshold`` — i.e. the
|
||||
face is nearly perpendicular to the screen plane. Provided as a
|
||||
pure predicate for callers that want to detect degenerate-drag
|
||||
conditions; the layout helper itself no longer gates on it.
|
||||
"""
|
||||
if len(face_normals_world) != 6:
|
||||
msg = f"expected 6 face normals, got {len(face_normals_world)}"
|
||||
raise ValueError(msg)
|
||||
vx, vy, vz = view_dir_world
|
||||
return tuple(abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure resize arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_face_resize(
|
||||
*,
|
||||
value: float,
|
||||
init_world_half: float,
|
||||
init_location: tuple[float, float, float],
|
||||
world_axis: tuple[float, float, float],
|
||||
display_size: float,
|
||||
) -> tuple[float, tuple[float, float, float]]:
|
||||
"""Pure one-sided face-resize arithmetic.
|
||||
|
||||
Returns ``(new_scale_axis, new_location)`` — the host's new scale
|
||||
on the dragged axis and its new world origin — such that the
|
||||
dragged face moves by the modal's outward delta while the OPPOSITE
|
||||
face stays put.
|
||||
|
||||
``value`` is ``init + delta``, where ``init`` is the unsigned
|
||||
drag-start world half-extent and ``delta`` is the cursor projection
|
||||
onto the face's OUTWARD world normal. Realized half-extent is
|
||||
clamped to a small floor; the location shift uses the realized
|
||||
(post-clamp) delta so the opposite face stays fixed even at the
|
||||
clamp.
|
||||
"""
|
||||
face_delta = value - init_world_half
|
||||
new_world_half = init_world_half + 0.5 * face_delta
|
||||
if new_world_half < _MIN_HALF_EXTENT:
|
||||
new_world_half = _MIN_HALF_EXTENT
|
||||
realized_delta = 2.0 * (new_world_half - init_world_half)
|
||||
|
||||
ds = display_size if display_size != 0.0 else 1.0
|
||||
new_scale_axis = new_world_half / ds
|
||||
shift = 0.5 * realized_delta
|
||||
new_location = (
|
||||
init_location[0] + shift * world_axis[0],
|
||||
init_location[1] + shift * world_axis[1],
|
||||
init_location[2] + shift * world_axis[2],
|
||||
)
|
||||
return new_scale_axis, new_location
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal geometry helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_face_quad_scale(bmin: Any, bmax: Any, axis: int, is_max: bool) -> tuple[float, float]:
|
||||
"""Return ``(w, h)`` for the face quad's scale matrix."""
|
||||
w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)]
|
||||
w = float(bmax[w_axis] - bmin[w_axis])
|
||||
h = float(bmax[h_axis] - bmin[h_axis])
|
||||
return w, h
|
||||
|
||||
|
||||
def _shared_edge_corner_keys(
|
||||
axis_a: int, is_max_a: bool, axis_b: int, is_max_b: bool
|
||||
) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None:
|
||||
"""Return the 2 corner-bit triples shared by two adjacent faces.
|
||||
|
||||
Corner keys are 3-tuples of bits (0 = bmin, 1 = bmax). The two
|
||||
returned corners are ordered with the free-axis bit ascending.
|
||||
"""
|
||||
if axis_a == axis_b:
|
||||
return None
|
||||
free_axis = 3 - axis_a - axis_b
|
||||
bit_a = 1 if is_max_a else 0
|
||||
bit_b = 1 if is_max_b else 0
|
||||
corner_lo = [0, 0, 0]
|
||||
corner_hi = [0, 0, 0]
|
||||
corner_lo[axis_a] = bit_a
|
||||
corner_hi[axis_a] = bit_a
|
||||
corner_lo[axis_b] = bit_b
|
||||
corner_hi[axis_b] = bit_b
|
||||
corner_lo[free_axis] = 0
|
||||
corner_hi[free_axis] = 1
|
||||
return (
|
||||
(corner_lo[0], corner_lo[1], corner_lo[2]),
|
||||
(corner_hi[0], corner_hi[1], corner_hi[2]),
|
||||
)
|
||||
|
||||
|
||||
def _face_corner_keys(axis: int, is_max: bool) -> tuple[
|
||||
tuple[int, int, int],
|
||||
tuple[int, int, int],
|
||||
tuple[int, int, int],
|
||||
tuple[int, int, int],
|
||||
]:
|
||||
"""Return the 4 corner-bit triples of a face in CCW order.
|
||||
|
||||
Triangulation as ``[(0,1,2), (0,2,3)]`` covers the whole face with
|
||||
two non-overlapping triangles.
|
||||
"""
|
||||
fixed_bit = 1 if is_max else 0
|
||||
free_axes = [a for a in (0, 1, 2) if a != axis]
|
||||
fa0, fa1 = free_axes
|
||||
corners = []
|
||||
for ka, kb in ((0, 0), (1, 0), (1, 1), (0, 1)):
|
||||
key = [0, 0, 0]
|
||||
key[axis] = fixed_bit
|
||||
key[fa0] = ka
|
||||
key[fa1] = kb
|
||||
corners.append((key[0], key[1], key[2]))
|
||||
return (corners[0], corners[1], corners[2], corners[3])
|
||||
|
||||
|
||||
def _build_strip_tris_relative(
|
||||
edge_p0_local: tuple[float, float, float],
|
||||
edge_p1_local: tuple[float, float, float],
|
||||
extrusion_local: tuple[float, float, float],
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Build two CCW triangles (6 vertices) for a thin halo strip.
|
||||
|
||||
All inputs are in coords relative to the gizmo's ``matrix_basis``
|
||||
anchor. The strip runs along ``[edge_p0_local, edge_p1_local]`` and
|
||||
extrudes by ``extrusion_local`` perpendicular to the edge.
|
||||
"""
|
||||
p0x, p0y, p0z = edge_p0_local
|
||||
p1x, p1y, p1z = edge_p1_local
|
||||
ex, ey, ez = extrusion_local
|
||||
p0e = (p0x + ex, p0y + ey, p0z + ez)
|
||||
p1e = (p1x + ex, p1y + ey, p1z + ez)
|
||||
return [
|
||||
(p0x, p0y, p0z),
|
||||
p0e,
|
||||
p1e,
|
||||
(p0x, p0y, p0z),
|
||||
p1e,
|
||||
(p1x, p1y, p1z),
|
||||
]
|
||||
|
||||
|
||||
def _strips_geometry_changed(quad_gz, face_quad_local, all_tris) -> bool:
|
||||
"""True if the back-face quad's geometry differs from the cached upload.
|
||||
|
||||
Pure orbit/pan doesn't change either the box pose or the cage
|
||||
rotation, so the computed strip vertices are byte-identical to the
|
||||
previous frame's. Hitting the cache lets the back-facing branch
|
||||
skip ``new_custom_shape`` and the GPU upload.
|
||||
"""
|
||||
cached = getattr(quad_gz, "_strips_cache_key", None)
|
||||
last_state = getattr(quad_gz, "_last_geometry_state", None)
|
||||
key = (face_quad_local, all_tris)
|
||||
if cached is None or last_state != "strips" or cached != key:
|
||||
quad_gz._strips_cache_key = key
|
||||
quad_gz._last_geometry_state = "strips"
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _compute_face_basis(
|
||||
mw: Any,
|
||||
mw_rot: Any,
|
||||
cage_rotation: Any,
|
||||
pivot_local: Any,
|
||||
face_local: Any,
|
||||
orient: Any,
|
||||
) -> tuple[Any, Any]:
|
||||
"""World-space (translation, outward-normal-direction) for one face."""
|
||||
rotated_face_local = cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local
|
||||
face_world = mw @ rotated_face_local
|
||||
world_axis = (mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0)))).normalized()
|
||||
return face_world, world_axis
|
||||
|
||||
|
||||
def _compose_face_matrix_basis(
|
||||
face_world: Any,
|
||||
mw_rot_scale: Any,
|
||||
cage_rotation: Any,
|
||||
orient: Any,
|
||||
w: float,
|
||||
h: float,
|
||||
) -> Any:
|
||||
"""Compose the 5-term ``matrix_basis`` for a face-plane gizmo.
|
||||
|
||||
Returns ``Translation @ mw_rot_scale @ cage_rotation @ orient @
|
||||
Diagonal((w, h, 1, 1))`` — maps a unit-square local quad onto the
|
||||
world-space face rectangle, including the host's scale.
|
||||
"""
|
||||
quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0))
|
||||
return Matrix.Translation(face_world) @ mw_rot_scale.to_4x4() @ cage_rotation @ orient @ quad_scale
|
||||
|
||||
|
||||
def _compute_box_corners_world(
|
||||
bmin: Any,
|
||||
bmax: Any,
|
||||
pivot_local: Any,
|
||||
cage_rotation_3x3: Any,
|
||||
mw: Any,
|
||||
) -> dict[tuple[int, int, int], Any]:
|
||||
"""Return the 8 OBB corners in world space, keyed by bit-triple."""
|
||||
corners: dict[tuple[int, int, int], Any] = {}
|
||||
for ix in (0, 1):
|
||||
for iy in (0, 1):
|
||||
for iz in (0, 1):
|
||||
local = Vector(
|
||||
(
|
||||
float(bmax.x if ix else bmin.x),
|
||||
float(bmax.y if iy else bmin.y),
|
||||
float(bmax.z if iz else bmin.z),
|
||||
)
|
||||
)
|
||||
rotated = cage_rotation_3x3 @ (local - pivot_local) + pivot_local
|
||||
corners[(ix, iy, iz)] = mw @ rotated
|
||||
return corners
|
||||
|
||||
|
||||
def _abs_scale_matrix(mw: Any) -> Any:
|
||||
"""Return a copy of ``mw`` with all scale components ``abs()``-ed.
|
||||
|
||||
Without this, a negative-scale host produces a visible/clickable
|
||||
face inversion: ``mw @ local_vec`` flips the +axis face onto the
|
||||
-axis world side, while the rotation-only normal stays pointing
|
||||
in the +axis direction — so the gizmo for "the +X face" sits at
|
||||
world -X but reports its outward normal as +X.
|
||||
"""
|
||||
loc, rot, scale = mw.decompose()
|
||||
abs_scale = Vector((abs(scale.x), abs(scale.y), abs(scale.z)))
|
||||
return Matrix.LocRotScale(loc, rot, abs_scale)
|
||||
|
||||
|
||||
def _world_radius_to_screen_pixels(
|
||||
region: Any,
|
||||
rv3d: Any,
|
||||
center_world: Vector,
|
||||
world_radius: float,
|
||||
*,
|
||||
min_pixels: float = 0.0,
|
||||
) -> float:
|
||||
"""Return the on-screen pixel radius of a world-space circle.
|
||||
|
||||
Projects ``center_world`` and a sample point offset by
|
||||
``world_radius`` along the camera's view-aligned right axis to
|
||||
region pixels, and returns the screen-pixel distance between them.
|
||||
Falls back to ``min_pixels`` if either projection fails.
|
||||
"""
|
||||
try:
|
||||
view_inv = rv3d.view_matrix.inverted()
|
||||
right = Vector((view_inv[0][0], view_inv[0][1], view_inv[0][2])).normalized()
|
||||
except (AttributeError, ValueError):
|
||||
right = Vector((1.0, 0.0, 0.0))
|
||||
sample_world = center_world + right * world_radius
|
||||
return _world_segment_to_screen_pixels(region, rv3d, center_world, sample_world, min_pixels=min_pixels)
|
||||
|
||||
|
||||
def _world_segment_to_screen_pixels(
|
||||
region: Any,
|
||||
rv3d: Any,
|
||||
p0_world: Vector,
|
||||
p1_world: Vector,
|
||||
*,
|
||||
min_pixels: float = 0.0,
|
||||
) -> float:
|
||||
"""Return the on-screen pixel length of an arbitrary world segment.
|
||||
|
||||
Unlike :func:`_world_radius_to_screen_pixels`, this measures the
|
||||
ACTUAL projected length of the segment — foreshortening included.
|
||||
Use this when the segment direction is known to be oblique to the
|
||||
screen plane (e.g. a back face's outward normal): a perpendicular
|
||||
radius measurement overestimates the on-screen length, leaving
|
||||
halo strips visually narrower than the requested pixel target.
|
||||
"""
|
||||
p0 = location_3d_to_region_2d(region, rv3d, p0_world)
|
||||
p1 = location_3d_to_region_2d(region, rv3d, p1_world)
|
||||
if not p0 or not p1:
|
||||
return min_pixels
|
||||
dx = float(p1[0]) - float(p0[0])
|
||||
dy = float(p1[1]) - float(p0[1])
|
||||
return max(min_pixels, (dx * dx + dy * dy) ** 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gizmo classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
|
||||
"""Near-invisible face-quad click target with drag-to-resize modal.
|
||||
|
||||
Geometry: a unit quad in the local XY plane at z=0. The adapter
|
||||
group's layout helper rotates and scales it onto the face plane;
|
||||
the quad is welded to the world face (``use_draw_scale = False``).
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_box_face_quad"
|
||||
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
|
||||
|
||||
__slots__ = (
|
||||
"custom_shape",
|
||||
"custom_shape_select",
|
||||
"init_value",
|
||||
"move_get_cb",
|
||||
"move_set_cb",
|
||||
"axis",
|
||||
"start_location",
|
||||
"depth_point",
|
||||
"callback",
|
||||
"ctrl_click_cb",
|
||||
"_group",
|
||||
"_face_axis",
|
||||
"is_max",
|
||||
"_drag_snapshot",
|
||||
"_last_geometry_state",
|
||||
"_strips_cache_key",
|
||||
)
|
||||
|
||||
def draw(self, context: Any) -> None:
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
def draw_select(self, context: Any, select_id: int) -> None:
|
||||
# Back-facing quads bind ``custom_shape_select`` to the halo-strip
|
||||
# TRIS so clicks OUTSIDE the box silhouette catch the back face.
|
||||
# Front-facing quads leave it None and reuse ``custom_shape``.
|
||||
shape = getattr(self, "custom_shape_select", None) or self.custom_shape
|
||||
self.draw_custom_shape(shape, select_id=select_id)
|
||||
|
||||
def setup(self) -> None:
|
||||
if not hasattr(self, "custom_shape_"):
|
||||
self.custom_shape = self.new_custom_shape("TRIS", _QUAD_TRIS)
|
||||
self.custom_shape_select = None
|
||||
# Quad welded to world geometry — clicks must align with the
|
||||
# visible face, not a screen-size widget. Disables Blender's
|
||||
# per-frame pixel-constant autoscale.
|
||||
self.use_draw_scale = False
|
||||
|
||||
# ---- modal -------------------------------------------------------------
|
||||
|
||||
def invoke(self, context: Any, event: Any) -> set[str]:
|
||||
# CTRL+click handoff: dispatch a host-defined callback (e.g.
|
||||
# align-view) instead of starting a drag.
|
||||
if event.ctrl and getattr(self, "ctrl_click_cb", None) is not None:
|
||||
self.ctrl_click_cb(context, event)
|
||||
return {"FINISHED"}
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
if region is None or rv3d is None:
|
||||
return {"CANCELLED"}
|
||||
self.init_value = self.move_get_cb()
|
||||
# Freeze the projection plane at invoke — projection-plane
|
||||
# drift on tilted axes causes exponential delta runaway.
|
||||
self.depth_point = self.matrix_basis.translation.copy()
|
||||
self.start_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
|
||||
|
||||
if getattr(self, "_group", None) is not None:
|
||||
self._group._lock_for(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def exit(self, context: Any, cancel: bool) -> None:
|
||||
try:
|
||||
if context.area:
|
||||
context.area.header_text_set(None)
|
||||
if cancel:
|
||||
self.move_set_cb(self.init_value)
|
||||
if hasattr(self, "callback"):
|
||||
self.callback(self.move_get_cb())
|
||||
finally:
|
||||
self._drag_snapshot = None
|
||||
if getattr(self, "_group", None) is not None:
|
||||
self._group._unlock_all()
|
||||
|
||||
def modal(self, context: Any, event: Any, tweak: set[str]) -> set[str]:
|
||||
if event.type == "ESC":
|
||||
return {"CANCELLED"}
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
if region is None or rv3d is None:
|
||||
return {"CANCELLED"}
|
||||
end_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
|
||||
delta = (end_location - self.start_location).dot(self.axis)
|
||||
if "SNAP" in tweak:
|
||||
delta = round(delta, 1)
|
||||
if "PRECISE" in tweak:
|
||||
delta /= 10.0
|
||||
self.move_set_cb(self.init_value + delta)
|
||||
if context.area:
|
||||
context.area.header_text_set(f"Value: {self.move_get_cb():.3f} ({delta:.3f})")
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
|
||||
"""Thin non-interactive colored edge outline for one face.
|
||||
|
||||
Drawn as 4 line segments in the face plane. The layout helper
|
||||
toggles its ``alpha`` between near-zero and ``1.0`` based on the
|
||||
sibling face-quad's ``is_highlight`` state — so hovering the quad
|
||||
lights up the matching outline. ``hide_select = True`` keeps the
|
||||
outline out of the GPU selection buffer.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_box_face_outline"
|
||||
bl_target_properties = ()
|
||||
|
||||
__slots__ = (
|
||||
"custom_shape",
|
||||
"_face_axis",
|
||||
"is_max",
|
||||
"_last_outline_state",
|
||||
)
|
||||
|
||||
def draw(self, context: Any) -> None:
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
def draw_select(self, context: Any, select_id: int) -> None:
|
||||
return None
|
||||
|
||||
def setup(self) -> None:
|
||||
if not hasattr(self, "custom_shape_"):
|
||||
self.custom_shape = self.new_custom_shape("LINES", _QUAD_OUTLINE_LINES)
|
||||
self.use_draw_scale = False
|
||||
self.hide_select = True
|
||||
self._last_outline_state = "unit"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-redraw orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_face_quad_layout(
|
||||
*,
|
||||
quad_gizmos,
|
||||
outline_gizmos,
|
||||
bmin: Any,
|
||||
bmax: Any,
|
||||
matrix_world: Any,
|
||||
cage_rotation: Any,
|
||||
region: Any,
|
||||
rv3d: Any,
|
||||
locked: bool,
|
||||
) -> None:
|
||||
"""Lay out 6 face quads + 6 outlines on the box for this redraw.
|
||||
|
||||
``quad_gizmos`` / ``outline_gizmos`` are length-6 sequences in
|
||||
:data:`FACE_ROUTES` order. ``bmin`` / ``bmax`` are the box corners
|
||||
in the host's local frame; ``matrix_world`` is the host's world
|
||||
matrix; ``cage_rotation`` is the OBB rotation as a 4x4 (use
|
||||
``Matrix.Identity(4)`` when rotation rides in ``matrix_world``).
|
||||
``region`` / ``rv3d`` drive the view-dependent front/back split and
|
||||
the screen-constant halo margin; passing ``rv3d = None`` bails.
|
||||
|
||||
Negative scale on the host is normalized to positive internally so
|
||||
the visible cube and the clickable face gizmos stay aligned —
|
||||
callers don't need to pre-process ``matrix_world``.
|
||||
|
||||
When ``locked`` (a drag is active), ``hide`` / ``select_bias``
|
||||
writes are skipped — the active quad's geometry is still refreshed
|
||||
so it tracks the moving box.
|
||||
"""
|
||||
if rv3d is None or getattr(rv3d, "view_rotation", None) is None:
|
||||
return
|
||||
if len(quad_gizmos) != 6 or len(outline_gizmos) != 6:
|
||||
return
|
||||
|
||||
mw = _abs_scale_matrix(matrix_world)
|
||||
mw_rot = mw.to_quaternion().to_matrix()
|
||||
mw_rot_scale = mw.to_3x3()
|
||||
cage_rotation_3x3 = cage_rotation.to_3x3()
|
||||
pivot_local = (bmin + bmax) * 0.5
|
||||
box_center_local = pivot_local
|
||||
face_midpoints_local = {
|
||||
(0, False): Vector((float(bmin.x), box_center_local.y, box_center_local.z)),
|
||||
(0, True): Vector((float(bmax.x), box_center_local.y, box_center_local.z)),
|
||||
(1, False): Vector((box_center_local.x, float(bmin.y), box_center_local.z)),
|
||||
(1, True): Vector((box_center_local.x, float(bmax.y), box_center_local.z)),
|
||||
(2, False): Vector((box_center_local.x, box_center_local.y, float(bmin.z))),
|
||||
(2, True): Vector((box_center_local.x, box_center_local.y, float(bmax.z))),
|
||||
}
|
||||
|
||||
view_dir = (rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))).normalized()
|
||||
view_dir_tuple = (float(view_dir.x), float(view_dir.y), float(view_dir.z))
|
||||
face_normals_world = []
|
||||
for route_axis, route_is_max in FACE_ROUTES:
|
||||
axis_local = Vector(face_outward_axis_local(route_axis, route_is_max))
|
||||
n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized()
|
||||
face_normals_world.append((float(n_world.x), float(n_world.y), float(n_world.z)))
|
||||
front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple)
|
||||
|
||||
box_center_world = mw @ pivot_local
|
||||
corners_world = _compute_box_corners_world(bmin, bmax, pivot_local, cage_rotation_3x3, mw)
|
||||
route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)}
|
||||
|
||||
for i, route in enumerate(FACE_ROUTES):
|
||||
quad_gz = quad_gizmos[i]
|
||||
is_front = front[i]
|
||||
axis_b, is_max_b = route
|
||||
|
||||
# Place the colored OUTLINE on every face using the same composed
|
||||
# face matrix the front-facing solid quad uses. Hidden/shown via
|
||||
# alpha at the end of the pass.
|
||||
outline_orient = _AXIS_ORIENT[route]
|
||||
outline_face_world, _outline_axis = _compute_face_basis(
|
||||
mw,
|
||||
mw_rot,
|
||||
cage_rotation,
|
||||
pivot_local,
|
||||
face_midpoints_local[route],
|
||||
outline_orient,
|
||||
)
|
||||
ow, oh = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
|
||||
outline_gizmos[i].matrix_basis = _compose_face_matrix_basis(
|
||||
outline_face_world, mw_rot_scale, cage_rotation, outline_orient, ow, oh
|
||||
)
|
||||
|
||||
if is_front:
|
||||
if not locked:
|
||||
quad_gz.hide = False
|
||||
quad_gz.select_bias = _FACE_QUAD_FRONT_FACING_SELECT_BIAS
|
||||
orient = _AXIS_ORIENT[route]
|
||||
face_world, world_axis = _compute_face_basis(
|
||||
mw,
|
||||
mw_rot,
|
||||
cage_rotation,
|
||||
pivot_local,
|
||||
face_midpoints_local[route],
|
||||
orient,
|
||||
)
|
||||
w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
|
||||
quad_gz.matrix_basis = _compose_face_matrix_basis(face_world, mw_rot_scale, cage_rotation, orient, w, h)
|
||||
quad_gz.axis = world_axis
|
||||
if getattr(quad_gz, "_last_geometry_state", None) != "solid":
|
||||
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS)
|
||||
quad_gz.custom_shape_select = None
|
||||
quad_gz._last_geometry_state = "solid"
|
||||
continue
|
||||
|
||||
# Back-facing: anchor at the back face centre; build halo strips
|
||||
# in the planes of the adjacent FRONT faces, extruded outside
|
||||
# the silhouette toward this face's outward normal.
|
||||
face_world = mw @ (cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + pivot_local)
|
||||
quad_gz.matrix_basis = Matrix.Translation(face_world)
|
||||
quad_gz.axis = (mw_rot @ cage_rotation_3x3 @ Vector(face_outward_axis_local(axis_b, is_max_b))).normalized()
|
||||
|
||||
adjacent_front_routes = [
|
||||
(axis_a, is_max_a)
|
||||
for axis_a in range(3)
|
||||
if axis_a != axis_b
|
||||
for is_max_a in (False, True)
|
||||
if front[route_to_index[(axis_a, is_max_a)]]
|
||||
]
|
||||
# Per-face world margin: measure the screen-projected length of
|
||||
# ONE world unit along THIS face's outward normal. The world
|
||||
# margin that yields ~N pixels on screen is then ``N / length``.
|
||||
# Foreshortening on oblique faces shortens the projected step,
|
||||
# so the world step must grow to keep the strip the same width
|
||||
# on screen.
|
||||
face_world_margin = 0.0
|
||||
if region is not None:
|
||||
sample_end = box_center_world + quad_gz.axis * 1.0
|
||||
screen_step = _world_segment_to_screen_pixels(region, rv3d, box_center_world, sample_end, min_pixels=0.0)
|
||||
if screen_step > 0.0:
|
||||
face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step
|
||||
if face_world_margin <= 0.0 or not adjacent_front_routes:
|
||||
if not locked:
|
||||
quad_gz.hide = True
|
||||
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
|
||||
if getattr(quad_gz, "_last_geometry_state", None) != "empty":
|
||||
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _EMPTY_TRIS)
|
||||
quad_gz.custom_shape_select = None
|
||||
quad_gz._last_geometry_state = "empty"
|
||||
continue
|
||||
|
||||
extrusion_world = quad_gz.axis * face_world_margin
|
||||
extrusion_local = (
|
||||
float(extrusion_world.x),
|
||||
float(extrusion_world.y),
|
||||
float(extrusion_world.z),
|
||||
)
|
||||
all_tris: list[tuple[float, float, float]] = []
|
||||
for axis_a, is_max_a in adjacent_front_routes:
|
||||
edge_keys = _shared_edge_corner_keys(axis_a, is_max_a, axis_b, is_max_b)
|
||||
if edge_keys is None:
|
||||
continue
|
||||
key0, key1 = edge_keys
|
||||
wp0 = corners_world[key0]
|
||||
wp1 = corners_world[key1]
|
||||
local_p0 = (
|
||||
float(wp0.x - face_world.x),
|
||||
float(wp0.y - face_world.y),
|
||||
float(wp0.z - face_world.z),
|
||||
)
|
||||
local_p1 = (
|
||||
float(wp1.x - face_world.x),
|
||||
float(wp1.y - face_world.y),
|
||||
float(wp1.z - face_world.z),
|
||||
)
|
||||
all_tris.extend(_build_strip_tris_relative(local_p0, local_p1, extrusion_local))
|
||||
|
||||
if not locked:
|
||||
quad_gz.hide = False
|
||||
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
|
||||
|
||||
corner_keys = _face_corner_keys(axis_b, is_max_b)
|
||||
wc_local = [
|
||||
(
|
||||
float(corners_world[k].x - face_world.x),
|
||||
float(corners_world[k].y - face_world.y),
|
||||
float(corners_world[k].z - face_world.z),
|
||||
)
|
||||
for k in corner_keys
|
||||
]
|
||||
face_quad_local = [
|
||||
wc_local[0],
|
||||
wc_local[1],
|
||||
wc_local[2],
|
||||
wc_local[0],
|
||||
wc_local[2],
|
||||
wc_local[3],
|
||||
]
|
||||
if _strips_geometry_changed(quad_gz, tuple(face_quad_local), tuple(all_tris)):
|
||||
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", face_quad_local)
|
||||
quad_gz.custom_shape_select = quad_gz.new_custom_shape("TRIS", all_tris)
|
||||
quad_gz._last_geometry_state = "strips"
|
||||
|
||||
# Outline alpha follows ONLY the hovered quad's own state — light
|
||||
# the outline of the face under the cursor, nothing else.
|
||||
if not locked:
|
||||
for outline_gz, quad_gz in zip(outline_gizmos, quad_gizmos, strict=True):
|
||||
lit = bool(getattr(quad_gz, "is_highlight", False))
|
||||
outline_gz.alpha = 1.0 if lit else 0.0
|
||||
outline_gz.alpha_highlight = 1.0 if lit else 0.0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AXIS_COLOR",
|
||||
"FACE_QUAD_ALPHA",
|
||||
"FACE_QUAD_ALPHA_HIGHLIGHT",
|
||||
"FACE_QUAD_SELECT_BIAS",
|
||||
"FACE_ROUTES",
|
||||
"BIM_GT_box_face_outline",
|
||||
"BIM_GT_box_face_quad",
|
||||
"apply_face_quad_layout",
|
||||
"compute_face_resize",
|
||||
"face_outward_axis_local",
|
||||
"front_facing_face_mask",
|
||||
"view_axis_parallel_face_mask",
|
||||
]
|
||||
@@ -0,0 +1,312 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Interactive face-quad resize gizmos for the active clip box.
|
||||
|
||||
Adapter group that binds the generic :mod:`face_quad` core to a Bonsai
|
||||
clip-box Empty: six near-invisible click quads + six edge outlines on
|
||||
the cube's faces. Dragging a face does a ONE-SIDED resize — the dragged
|
||||
face moves along its outward world normal while the opposite face stays
|
||||
put — by writing the empty's ``location`` and ``scale``. Bonsai's
|
||||
depsgraph handler then re-arms the clip planes from the new matrix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import face_quad
|
||||
|
||||
# Local-frame bounds of the empty's CUBE display. The display spans
|
||||
# ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets
|
||||
# ``empty_display_size = 1.0`` on clip-box hosts, so the local box is
|
||||
# the unit cube. The empty's per-axis scale + rotation + translation
|
||||
# ride in ``matrix_world``, which the layout helper applies.
|
||||
_LOCAL_BMIN = Vector((-1.0, -1.0, -1.0))
|
||||
_LOCAL_BMAX = Vector((1.0, 1.0, 1.0))
|
||||
|
||||
|
||||
def _world_axis(empty: bpy.types.Object, axis: int, is_max: bool) -> Vector:
|
||||
"""Outward world-space unit normal of the ``(axis, is_max)`` face.
|
||||
|
||||
Uses the rotation-only matrix so a negative-scale empty doesn't
|
||||
flip the resulting direction — the visible "+X face" then stays
|
||||
associated with world +X (transformed through rotation).
|
||||
"""
|
||||
rot_mat = empty.matrix_world.to_quaternion().to_matrix()
|
||||
n = Vector(rot_mat.col[axis])
|
||||
if n.length <= 0.0:
|
||||
return Vector((0.0, 0.0, 0.0))
|
||||
n.normalize()
|
||||
return n if is_max else -n
|
||||
|
||||
|
||||
def _world_half_extent(empty: bpy.types.Object, axis: int) -> float:
|
||||
"""The empty's box half-extent along local ``axis`` in WORLD units.
|
||||
|
||||
A CUBE empty's local cube is ``±empty_display_size``; ``matrix_world``
|
||||
stretches it by the column length on ``axis``. So the world
|
||||
half-extent is ``|column[axis]| * empty_display_size``.
|
||||
"""
|
||||
col_len = empty.matrix_world.to_3x3().col[axis].length
|
||||
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
|
||||
return float(col_len) * display_size
|
||||
|
||||
|
||||
def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool):
|
||||
"""Closure returning the world half-extent at drag start and
|
||||
snapshotting the empty's full transform on the gizmo instance.
|
||||
|
||||
The snapshot lives on the gizmo (not the group) so a PERSISTENT
|
||||
group servicing multiple clip boxes can't bleed one drag's state
|
||||
onto another. Cleared on ``exit`` by the shared face-quad hook.
|
||||
"""
|
||||
|
||||
def getter() -> float:
|
||||
empty = group._empty
|
||||
if empty is None:
|
||||
return 0.0
|
||||
existing = getattr(gz, "_drag_snapshot", None)
|
||||
if existing is not None and existing.get("empty_name") == getattr(empty, "name", None):
|
||||
return float(existing["world_half"])
|
||||
|
||||
world_half = _world_half_extent(empty, axis)
|
||||
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
|
||||
gz._drag_snapshot = {
|
||||
"empty_name": getattr(empty, "name", None),
|
||||
"world_half": world_half,
|
||||
"location": tuple(float(v) for v in empty.location),
|
||||
"scale": tuple(float(v) for v in empty.scale),
|
||||
"display_size": display_size if display_size != 0.0 else 1.0,
|
||||
"world_axis": tuple(_world_axis(empty, axis, is_max)),
|
||||
}
|
||||
return float(world_half)
|
||||
|
||||
return getter
|
||||
|
||||
|
||||
def _make_ctrl_click_cb(axis: int, is_max: bool):
|
||||
"""Closure that dispatches CTRL+click on a face to the align-view operator.
|
||||
|
||||
Routing through an operator (rather than mutating ``rv3d`` here)
|
||||
keeps the action F3-searchable and undoable.
|
||||
"""
|
||||
|
||||
def _callback(_context: Any, _event: Any) -> None:
|
||||
bpy.ops.bim.align_view_to_clip_face("INVOKE_DEFAULT", axis=axis, is_max=is_max)
|
||||
|
||||
return _callback
|
||||
|
||||
|
||||
def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool):
|
||||
"""Closure that applies a one-sided face resize by writing the
|
||||
empty's ``location`` + ``scale``.
|
||||
|
||||
The modal calls this with ``value = init + delta`` where ``delta``
|
||||
is the cursor's projection onto the face's OUTWARD world normal.
|
||||
Both reads come from ``gz._drag_snapshot`` so every frame is
|
||||
relative to drag start, never compounding.
|
||||
"""
|
||||
del is_max # snapshot's world_axis carries the direction
|
||||
|
||||
def setter(value: float) -> None:
|
||||
empty = group._empty
|
||||
if empty is None:
|
||||
return
|
||||
snap = getattr(gz, "_drag_snapshot", None)
|
||||
if snap is None or snap.get("empty_name") != getattr(empty, "name", None):
|
||||
return
|
||||
|
||||
new_scale_axis, new_location = face_quad.compute_face_resize(
|
||||
value=value,
|
||||
init_world_half=snap["world_half"],
|
||||
init_location=snap["location"],
|
||||
world_axis=snap["world_axis"],
|
||||
display_size=snap["display_size"],
|
||||
)
|
||||
new_scale = list(snap["scale"])
|
||||
# Preserve the sign of the original scale so a user-flipped empty
|
||||
# stays flipped after the resize — compute_face_resize returns a
|
||||
# positive magnitude, the sign is the user's intent to keep.
|
||||
sign = -1.0 if snap["scale"][axis] < 0.0 else 1.0
|
||||
new_scale[axis] = sign * new_scale_axis
|
||||
|
||||
empty.scale = new_scale
|
||||
empty.location = Vector(new_location)
|
||||
|
||||
return setter
|
||||
|
||||
|
||||
class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention
|
||||
"""Face-quad resize handles on the active clip box.
|
||||
|
||||
Renders six near-invisible click-target quads and six colored edge
|
||||
outlines on the active clip-box empty whenever clipping is enabled.
|
||||
Click-and-drag a face to resize one-sided; the opposite face stays
|
||||
put. CTRL+click and plain click fall through to selection.
|
||||
"""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_clip_box"
|
||||
bl_label = "Bonsai Clip Box Faces"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Any) -> bool:
|
||||
scene = getattr(context, "scene", None)
|
||||
if scene is None:
|
||||
return False
|
||||
scene_props = tool.ClipBox.get_scene_props(scene)
|
||||
if not scene_props.enabled or not scene_props.enable_gizmos:
|
||||
return False
|
||||
active_clip_box = tool.ClipBox.get_active_clip_box(scene)
|
||||
if active_clip_box is None:
|
||||
return False
|
||||
# Only render when the user has the active clip box itself
|
||||
# selected — otherwise the face handles would intercept clicks
|
||||
# meant for the geometry behind them.
|
||||
return getattr(context, "active_object", None) is active_clip_box
|
||||
|
||||
@classmethod
|
||||
def setup_keymap(cls, keyconfig):
|
||||
# Bind CLICK_DRAG so plain LEFTMOUSE PRESS passes through to
|
||||
# selection — the user can still click through a near-invisible
|
||||
# face quad to pick a mesh behind it.
|
||||
km = keyconfig.keymaps.new(
|
||||
name=cls.bl_idname,
|
||||
space_type=cls.bl_space_type,
|
||||
region_type=cls.bl_region_type,
|
||||
)
|
||||
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG")
|
||||
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True)
|
||||
return km
|
||||
|
||||
def setup(self, context: Any) -> None:
|
||||
# ``_empty`` is resolved each refresh so the PERSISTENT group
|
||||
# follows whichever clip box is active in the scene PG.
|
||||
self._empty: bpy.types.Object | None = None
|
||||
self._locked = False
|
||||
self._face_routes: list[tuple[int, bool]] = []
|
||||
|
||||
for axis, is_max in face_quad.FACE_ROUTES:
|
||||
gz = self.gizmos.new(face_quad.BIM_GT_box_face_quad.bl_idname)
|
||||
gz._group = self
|
||||
gz._face_axis = axis
|
||||
gz.is_max = is_max
|
||||
gz._drag_snapshot = None
|
||||
gz._last_geometry_state = "solid"
|
||||
gz._strips_cache_key = None
|
||||
gz.color = face_quad.AXIS_COLOR[axis]
|
||||
gz.color_highlight = tuple(min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis])
|
||||
gz.alpha = face_quad.FACE_QUAD_ALPHA
|
||||
gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT
|
||||
gz.use_draw_modal = True
|
||||
gz.scale_basis = 1.0
|
||||
gz.select_bias = face_quad.FACE_QUAD_SELECT_BIAS
|
||||
gz.move_get_cb = _make_face_get_cb(gz, self, axis, is_max)
|
||||
gz.move_set_cb = _make_face_set_cb(gz, self, axis, is_max)
|
||||
# CTRL+click on a face aligns the viewport to look at it.
|
||||
gz.ctrl_click_cb = _make_ctrl_click_cb(axis, is_max)
|
||||
self._face_routes.append((axis, is_max))
|
||||
|
||||
# Outlines added last so they composite on top of the quad
|
||||
# fills (Blender draws gizmos in creation order).
|
||||
for axis, is_max in face_quad.FACE_ROUTES:
|
||||
ol = self.gizmos.new(face_quad.BIM_GT_box_face_outline.bl_idname)
|
||||
ol._face_axis = axis
|
||||
ol.is_max = is_max
|
||||
ol.color = face_quad.AXIS_COLOR[axis]
|
||||
ol.color_highlight = face_quad.AXIS_COLOR[axis]
|
||||
ol.alpha = 0.0
|
||||
ol.alpha_highlight = 0.0
|
||||
ol.line_width = 2.5
|
||||
|
||||
def _quad_gizmos(self):
|
||||
return self.gizmos[: len(self._face_routes)]
|
||||
|
||||
def _outline_gizmos(self):
|
||||
n = len(self._face_routes)
|
||||
return self.gizmos[n : 2 * n]
|
||||
|
||||
def refresh(self, context: Any) -> None:
|
||||
"""State-change path: resolve the active empty, then run the
|
||||
shared face-quad layout so the quads aren't stale for a frame
|
||||
after a selection or active-index change."""
|
||||
empty = tool.ClipBox.get_active_clip_box(context.scene)
|
||||
self._empty = empty
|
||||
if empty is None:
|
||||
for gz in self.gizmos:
|
||||
gz.hide = True
|
||||
return
|
||||
self._layout(context, empty)
|
||||
|
||||
def draw_prepare(self, context: Any) -> None:
|
||||
"""Per-redraw — fires on orbit — re-run the layout so the
|
||||
front/back split, halo strips, and outline highlights track
|
||||
the camera and any live G/R/S on the empty."""
|
||||
empty = self._empty
|
||||
if empty is None:
|
||||
return
|
||||
self._layout(context, empty)
|
||||
|
||||
def _layout(self, context: Any, empty: bpy.types.Object) -> None:
|
||||
face_quad.apply_face_quad_layout(
|
||||
quad_gizmos=self._quad_gizmos(),
|
||||
outline_gizmos=self._outline_gizmos(),
|
||||
bmin=_LOCAL_BMIN,
|
||||
bmax=_LOCAL_BMAX,
|
||||
matrix_world=empty.matrix_world,
|
||||
# The empty's rotation rides in matrix_world, so the
|
||||
# box-local OBB rotation is identity.
|
||||
cage_rotation=Matrix.Identity(4),
|
||||
region=getattr(context, "region", None),
|
||||
rv3d=getattr(context, "region_data", None),
|
||||
locked=self._locked,
|
||||
)
|
||||
|
||||
# ---- mutual exclusion (lock siblings during a drag) ------------------
|
||||
|
||||
def _lock_for(self, active_gizmo) -> None:
|
||||
self._locked = True
|
||||
for gz in self.gizmos:
|
||||
if gz is not active_gizmo:
|
||||
with contextlib.suppress(ReferenceError, RuntimeError):
|
||||
gz.hide = True
|
||||
|
||||
def _unlock_all(self) -> None:
|
||||
self._locked = False
|
||||
for gz in self.gizmos:
|
||||
with contextlib.suppress(ReferenceError, RuntimeError):
|
||||
gz.hide = False
|
||||
# Rebuild caps synchronously so the cross-section overlay
|
||||
# re-forms the instant the user releases the handle, rather
|
||||
# than waiting for the depsgraph's debounced rebuild path.
|
||||
with contextlib.suppress(RuntimeError, ReferenceError):
|
||||
tool.ClipBox.rebuild_caps_now()
|
||||
# Push an undo step so the user can revert a face drag with Ctrl+Z.
|
||||
with contextlib.suppress(RuntimeError):
|
||||
bpy.ops.ed.undo_push(message="Resize Clip Box")
|
||||
@@ -0,0 +1,294 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
|
||||
from . import data
|
||||
|
||||
# NOTE: do NOT add ``from __future__ import annotations`` to this module.
|
||||
# PEP 563 stringifies the operator's EnumProperty class annotations, which
|
||||
# breaks any introspection that reads ``cls.__annotations__[name].keywords``
|
||||
# — including the enum-search helper that draws the search-button icon.
|
||||
|
||||
CLIP_BOX_NAME = "ClipBox"
|
||||
|
||||
# Display labels for the source-based picker, used for the menu entries and
|
||||
# the dialog title. The dict keys are the canonical source-kind identifiers.
|
||||
SOURCE_KIND_LABELS: dict[str, str] = {
|
||||
"SPATIAL": "Spatial Element",
|
||||
"CLASS": "Class",
|
||||
"TYPE": "Type",
|
||||
"MATERIAL": "Material",
|
||||
"PROFILE": "Profile",
|
||||
"DRAWING": "Drawing",
|
||||
"STATUS": "Status",
|
||||
"SYSTEM": "System",
|
||||
"GROUP": "Group",
|
||||
"ZONE": "Zone",
|
||||
}
|
||||
|
||||
|
||||
_SOURCE_ID_DISPATCH = {
|
||||
"SPATIAL": data.spatial_items,
|
||||
"CLASS": data.class_items,
|
||||
"TYPE": data.type_items,
|
||||
"MATERIAL": data.material_items,
|
||||
"PROFILE": data.profile_items,
|
||||
"DRAWING": data.drawing_items,
|
||||
"STATUS": data.status_items,
|
||||
"SYSTEM": data.system_items,
|
||||
"GROUP": data.group_items,
|
||||
"ZONE": data.zone_items,
|
||||
}
|
||||
|
||||
|
||||
def _source_id_items(self, context):
|
||||
"""Dispatch the ``source_id`` enum items based on the picked ``source_kind``."""
|
||||
fn = _SOURCE_ID_DISPATCH.get(self.source_kind)
|
||||
if fn is None:
|
||||
return [(data.NO_OPTIONS_ID, "No options", "")]
|
||||
return fn(self, context)
|
||||
|
||||
|
||||
def _source_display_name(kind, source_id):
|
||||
"""Human-readable name of the picked source, used in the clip-box name."""
|
||||
if kind == "STATUS":
|
||||
return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id)
|
||||
if kind == "CLASS":
|
||||
# source_id IS the human-readable IFC class name.
|
||||
return source_id
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return source_id
|
||||
try:
|
||||
entity = ifc.by_id(int(source_id))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
return source_id
|
||||
return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
|
||||
|
||||
|
||||
class BIM_OT_align_view_to_clip_face(bpy.types.Operator):
|
||||
bl_idname = "bim.align_view_to_clip_face"
|
||||
bl_label = "Align View to Clip Box Face"
|
||||
bl_description = "Orient the 3D viewport to look directly at the picked clip-box face"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"})
|
||||
is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
|
||||
|
||||
def execute(self, context):
|
||||
rv3d = getattr(context, "region_data", None)
|
||||
if rv3d is None:
|
||||
return {"CANCELLED"}
|
||||
clip_box = tool.ClipBox.get_active_clip_box(context.scene)
|
||||
if clip_box is None:
|
||||
return {"CANCELLED"}
|
||||
rot_mat = clip_box.matrix_world.to_quaternion().to_matrix()
|
||||
outward_local = Vector((0.0, 0.0, 0.0))
|
||||
outward_local[self.axis] = 1.0 if self.is_max else -1.0
|
||||
outward = (rot_mat @ outward_local).normalized()
|
||||
if outward.length == 0.0:
|
||||
return {"CANCELLED"}
|
||||
up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized()
|
||||
rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _local_up_for_face(axis: int, is_max: bool) -> Vector:
|
||||
"""Box-local up direction for a face, following Blender numpad conventions.
|
||||
|
||||
Side faces (local ±X / ±Y normal) → local +Z is up. Top face (local +Z
|
||||
normal) → local +Y is up; bottom face (local -Z normal) → local -Y is
|
||||
up. The caller rotates this through the empty's matrix so the
|
||||
resulting world up axis tracks the box's orientation.
|
||||
"""
|
||||
if axis == 2:
|
||||
return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0))
|
||||
return Vector((0.0, 0.0, 1.0))
|
||||
|
||||
|
||||
def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion":
|
||||
"""Build a camera ``view_rotation`` that looks along ``forward`` with
|
||||
``up_hint`` projected to the camera's local +Y."""
|
||||
back = -forward.normalized()
|
||||
right = up_hint.cross(back)
|
||||
if right.length < 1e-6:
|
||||
right = Vector((1.0, 0.0, 0.0))
|
||||
right.normalize()
|
||||
up = back.cross(right).normalized()
|
||||
return Matrix(
|
||||
(
|
||||
(right.x, up.x, back.x),
|
||||
(right.y, up.y, back.y),
|
||||
(right.z, up.z, back.z),
|
||||
)
|
||||
).to_quaternion()
|
||||
|
||||
|
||||
class BIM_OT_add_clip_box(bpy.types.Operator):
|
||||
bl_idname = "bim.add_clip_box"
|
||||
bl_label = "Add Clip Box"
|
||||
bl_description = (
|
||||
"Create a clip box empty at the 3D cursor. The empty's location, rotation, and scale "
|
||||
"drive the viewport clip planes; resize with S, move with G, rotate with R"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
# Default to a 20m cube (scale 10 around [-1, +1] local cube) so
|
||||
# the volume covers a typical building storey or two rather than
|
||||
# the meaningless 2m unit cube. The user resizes with S.
|
||||
matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal((10.0, 10.0, 10.0, 1.0))
|
||||
tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_add_clip_box_for_source(bpy.types.Operator):
|
||||
bl_idname = "bim.add_clip_box_for_source"
|
||||
bl_label = "Add Clip Box From Source"
|
||||
bl_description = (
|
||||
"Create a clip box sized to a chosen source: a spatial container, IFC type, material, "
|
||||
"profile, drawing camera frustum, element status, system, group, or zone"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
source_kind: bpy.props.EnumProperty(
|
||||
name="Source Kind",
|
||||
items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()],
|
||||
default="SPATIAL",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
source_id: bpy.props.EnumProperty(
|
||||
name="Source",
|
||||
items=_source_id_items,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}"
|
||||
# Search button appears once the enum exceeds the helper's threshold,
|
||||
# giving the user a popup picker instead of a plain dropdown.
|
||||
prop_with_search(layout, self, "source_id", text=label)
|
||||
|
||||
def execute(self, context):
|
||||
if not self.source_id or self.source_id == data.NO_OPTIONS_ID:
|
||||
self.report({"ERROR"}, "No source selected.")
|
||||
return {"CANCELLED"}
|
||||
matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id)
|
||||
if matrix is None:
|
||||
kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}"
|
||||
tool.ClipBox.create_clip_box_empty(context, matrix, name=name)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_remove_clip_box(bpy.types.Operator):
|
||||
bl_idname = "bim.remove_clip_box"
|
||||
bl_label = "Remove Clip Box"
|
||||
bl_description = "Remove this clip box and its host empty"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
|
||||
delete_object: bpy.props.BoolProperty(default=True, name="Delete Host Object")
|
||||
|
||||
def execute(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
index = self.index if self.index >= 0 else scene_props.active_clip_box_index
|
||||
if index < 0 or index >= len(scene_props.clip_boxes):
|
||||
return {"CANCELLED"}
|
||||
|
||||
entry = scene_props.clip_boxes[index]
|
||||
obj = entry.obj
|
||||
scene_props.clip_boxes.remove(index)
|
||||
if scene_props.active_clip_box_index >= len(scene_props.clip_boxes):
|
||||
scene_props.active_clip_box_index = max(0, len(scene_props.clip_boxes) - 1)
|
||||
|
||||
if self.delete_object and obj is not None:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
tool.ClipBox.refresh(context.scene)
|
||||
tool.ClipBox.save_to_project_pset(context.scene)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_set_active_clip_box(bpy.types.Operator):
|
||||
bl_idname = "bim.set_active_clip_box"
|
||||
bl_label = "Set Active Clip Box"
|
||||
bl_description = "Set this clip box as the active one driving the viewport clip"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
|
||||
|
||||
def execute(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
if self.index < 0 or self.index >= len(scene_props.clip_boxes):
|
||||
return {"CANCELLED"}
|
||||
scene_props.active_clip_box_index = self.index
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_toggle_clip_box_enabled(bpy.types.Operator):
|
||||
bl_idname = "bim.toggle_clip_box_enabled"
|
||||
bl_label = "Toggle Clip Box"
|
||||
bl_description = "Toggle whether the active clip box is driving the viewport clip planes"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
scene_props.enabled = not scene_props.enabled
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_duplicate_clip_box(bpy.types.Operator):
|
||||
bl_idname = "bim.duplicate_clip_box"
|
||||
bl_label = "Duplicate Clip Box"
|
||||
bl_description = "Duplicate this clip box: copy its empty + matrix into a new entry"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
|
||||
|
||||
def execute(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
source_index = self.index if self.index >= 0 else scene_props.active_clip_box_index
|
||||
if source_index < 0 or source_index >= len(scene_props.clip_boxes):
|
||||
return {"CANCELLED"}
|
||||
source = scene_props.clip_boxes[source_index].obj
|
||||
if source is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
copy = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name)
|
||||
# Preserve the source's display attrs so the duplicate matches.
|
||||
copy.empty_display_type = source.empty_display_type
|
||||
copy.empty_display_size = source.empty_display_size
|
||||
copy.show_in_front = source.show_in_front
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,165 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
from bpy.types import PropertyGroup
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.prop import ObjProperty
|
||||
|
||||
|
||||
class BIMClipBoxProperties(PropertyGroup):
|
||||
"""Per-object marker for a clip-box host empty.
|
||||
|
||||
The host empty's ``matrix_world`` is the single source of truth for
|
||||
the clip box's pose and dimensions: translation = box centre,
|
||||
rotation = box orientation, per-axis scale = world half-extents. The
|
||||
visible cube comes from the empty's CUBE display.
|
||||
|
||||
Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay
|
||||
(``show_caps``) are global per-file and live on the Scene PG.
|
||||
"""
|
||||
|
||||
is_clip_box: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True when this empty was created as a clip-box host. Internal flag; not user-edited.",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_clip_box: bool
|
||||
|
||||
|
||||
def update_active_clip_box_index(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
tool.ClipBox.select_active_clip_box(context)
|
||||
# Rebuild caps for the new active box's clip volume.
|
||||
tool.ClipBox.invalidate_cap_cache(immediate=True)
|
||||
|
||||
|
||||
def update_show_caps(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
# Off → on must trigger a rebuild so caps reappear immediately rather
|
||||
# than wait for the next depsgraph tick. The rebuild is a no-op when
|
||||
# show_caps is now False (it clears and returns), so this is safe in
|
||||
# both directions.
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
def update_enabled(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
|
||||
|
||||
def update_clip_only_ifc_products(self, context):
|
||||
# The eligibility set for capping changed — drop the cache and let the
|
||||
# debounced rebuild pick up the new objects on the next idle tick.
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
def update_include_linked_ifc(self, context):
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
class BIMSceneClipBoxProperties(PropertyGroup):
|
||||
"""Scene-level registry of clip boxes in this file.
|
||||
|
||||
Multiple boxes may exist; ``active_clip_box_index`` selects which one
|
||||
drives the viewport clip at any time. ``enabled`` and ``show_caps``
|
||||
are global because the user's intent ("hide everything outside the
|
||||
box", "draw cap overlays") applies file-wide, not per box.
|
||||
|
||||
``enabled`` is intentionally not persisted to the project pset:
|
||||
opening a fresh IFC should never silently hide geometry behind a
|
||||
remembered toggle. Selecting any clip-box empty in the viewport
|
||||
re-arms it (see :meth:`tool.ClipBox._sync_active_to_selection`).
|
||||
"""
|
||||
|
||||
clip_boxes: bpy.props.CollectionProperty(type=ObjProperty)
|
||||
active_clip_box_index: bpy.props.IntProperty(
|
||||
default=0,
|
||||
min=0,
|
||||
update=update_active_clip_box_index,
|
||||
description="Index of the clip box currently driving the viewport clip planes",
|
||||
)
|
||||
enabled: bpy.props.BoolProperty(
|
||||
name="Enabled",
|
||||
default=False,
|
||||
update=update_enabled,
|
||||
description="When enabled, the active clip box hides all viewport geometry outside its 6 faces",
|
||||
)
|
||||
show_caps: bpy.props.BoolProperty(
|
||||
name="Show Caps",
|
||||
default=True,
|
||||
update=update_show_caps,
|
||||
description=(
|
||||
"Draw filled cross-section caps where IFC product geometry "
|
||||
"crosses the active clip planes. Disable for performance on "
|
||||
"very heavy scenes"
|
||||
),
|
||||
)
|
||||
# Stored on the Scene PG so Blender persists it in the .blend; deliberately
|
||||
# NOT written to the project pset so the IFC stays portable across users
|
||||
# who may have different Blender-side reference geometry to clip.
|
||||
clip_only_ifc_products: bpy.props.BoolProperty(
|
||||
name="Only IFC Products",
|
||||
default=True,
|
||||
update=update_clip_only_ifc_products,
|
||||
description=(
|
||||
"When enabled, only IFC element geometry gets cross-section caps. "
|
||||
"Disable to also cap Blender-side reference meshes (sketches, "
|
||||
"imported obj, primitive cubes, …)"
|
||||
),
|
||||
)
|
||||
# Opt-in inclusion of geometry sitting inside loaded Project › Links
|
||||
# collection-instance empties. Off by default — linked IFCs commonly
|
||||
# carry the entire site / structural / MEP context, and bisecting
|
||||
# them on every clip-box edit can be expensive.
|
||||
include_linked_ifc: bpy.props.BoolProperty(
|
||||
name="Include Linked IFC",
|
||||
default=False,
|
||||
update=update_include_linked_ifc,
|
||||
description=(
|
||||
"Also generate cross-section caps for geometry inside linked "
|
||||
"IFC files (Project ▸ Links). Off by default — linked IFCs may "
|
||||
"carry the entire site / structural backbone, and capping them "
|
||||
"adds per-mesh bisect cost on every clip-box edit"
|
||||
),
|
||||
)
|
||||
# Also Scene-only — gizmo visibility is a per-user editing preference,
|
||||
# not a portable IFC property.
|
||||
enable_gizmos: bpy.props.BoolProperty(
|
||||
name="Show Face Handles",
|
||||
default=True,
|
||||
description=(
|
||||
"Show interactive face-resize handles on the active clip box. "
|
||||
"Disable to fall back to plain G/R/S transforms on the empty"
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
active_clip_box_index: int
|
||||
enabled: bool
|
||||
show_caps: bool
|
||||
clip_only_ifc_products: bool
|
||||
include_linked_ifc: bool
|
||||
enable_gizmos: bool
|
||||
@@ -0,0 +1,145 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
# Per-kind icon for the source-picker menu. Picked from Blender's built-in
|
||||
# icon set; semantically close to the kind so users can scan the menu visually.
|
||||
_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = (
|
||||
("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"),
|
||||
("CLASS", "Clip by Class", "BLANK1"),
|
||||
("TYPE", "Clip Type", "FILE_3D"),
|
||||
("MATERIAL", "Clip Material", "MATERIAL"),
|
||||
("PROFILE", "Clip Profile", "MESH_CIRCLE"),
|
||||
("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"),
|
||||
("STATUS", "Clip by Status", "INFO"),
|
||||
("SYSTEM", "Clip by System", "MOD_FLUID"),
|
||||
("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"),
|
||||
("ZONE", "Clip by Zone", "MOD_LATTICE"),
|
||||
)
|
||||
|
||||
|
||||
class BIM_MT_clip_box_add_for_source(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_add_for_source"
|
||||
bl_label = "Add Clip Box From Source"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
for kind, label, icon in _SOURCE_MENU_ENTRIES:
|
||||
op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon)
|
||||
op.source_kind = kind
|
||||
|
||||
|
||||
class BIM_MT_clip_box_settings(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_settings"
|
||||
bl_label = "Clip Box Settings"
|
||||
|
||||
def draw(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
self.layout.prop(scene_props, "clip_only_ifc_products")
|
||||
self.layout.prop(scene_props, "include_linked_ifc")
|
||||
self.layout.prop(scene_props, "enable_gizmos")
|
||||
|
||||
|
||||
class BIM_MT_clip_box_info(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_info"
|
||||
bl_label = "Clip Box Face Handles"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Face Handles", icon="INFO")
|
||||
layout.separator()
|
||||
layout.label(text="Drag a face to resize the clip box on that axis.")
|
||||
layout.label(text="The opposite face stays fixed (one-sided resize).")
|
||||
layout.label(text="Ctrl+Click a face to align the viewport to it.")
|
||||
layout.separator()
|
||||
layout.label(text="Toggle handles from the Settings (gear) menu.")
|
||||
|
||||
|
||||
class BIM_UL_clip_box(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
|
||||
obj = item.obj
|
||||
row = layout.row(align=True)
|
||||
if obj is None:
|
||||
# Host empty was deleted from outliner; still expose the
|
||||
# remove button so the orphan entry isn't permanent.
|
||||
row.label(text="(missing)", icon="ERROR")
|
||||
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
|
||||
return
|
||||
row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE")
|
||||
row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index
|
||||
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
|
||||
|
||||
|
||||
class BIM_PT_clip_box(Panel):
|
||||
bl_idname = "BIM_PT_clip_box"
|
||||
bl_label = "Clip Box"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
bl_parent_id = "BIM_PT_tab_sandbox"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
|
||||
toggles = layout.row(align=True)
|
||||
toggles.scale_y = 2.0
|
||||
toggles.prop(
|
||||
scene_props,
|
||||
"enabled",
|
||||
text="Enable Clipping",
|
||||
icon="HIDE_OFF" if scene_props.enabled else "HIDE_ON",
|
||||
toggle=True,
|
||||
)
|
||||
toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True)
|
||||
toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="")
|
||||
toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="")
|
||||
|
||||
layout.separator()
|
||||
row = layout.row(align=True)
|
||||
row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
|
||||
row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="")
|
||||
|
||||
layout.template_list(
|
||||
"BIM_UL_clip_box",
|
||||
"",
|
||||
scene_props,
|
||||
"clip_boxes",
|
||||
scene_props,
|
||||
"active_clip_box_index",
|
||||
rows=3,
|
||||
)
|
||||
|
||||
obj = tool.ClipBox.get_active_clip_box(context.scene)
|
||||
if obj is None:
|
||||
layout.label(text="No active clip box", icon="INFO")
|
||||
return
|
||||
|
||||
col = layout.column(align=True)
|
||||
col.label(text="Edit the empty with G / R / S to move / rotate / resize")
|
||||
col.prop(obj, "location")
|
||||
col.prop(obj, "rotation_euler")
|
||||
col.prop(obj, "scale")
|
||||
@@ -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,34 @@ classes = (
|
||||
gizmos.GizmoArrow2D,
|
||||
gizmos.GizmoCone,
|
||||
gizmos.GizmoDimension,
|
||||
gizmos.GizmoLock,
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
gizmos.GizmoArc,
|
||||
gizmos.GizmoLinkToggle,
|
||||
gizmos.GizmoFillet,
|
||||
gizmos.GizmoWallCornerIcon,
|
||||
gizmos.GizmoWallTeeIcon,
|
||||
gizmos.GizmoPen,
|
||||
gizmos.GizmoValidate,
|
||||
gizmos.GizmoCancel,
|
||||
gizmos.GizmoPlus,
|
||||
gizmos.GizmoMinus,
|
||||
gizmos.GizmoTrash,
|
||||
gizmos.GizmoArrayParent,
|
||||
gizmos.GizmoArrayAll,
|
||||
gizmos.GizmoArrayLayerIndicator,
|
||||
gizmos.GizmoCountLabel,
|
||||
gizmos.GizmoMerge,
|
||||
gizmos.GizmoSplit,
|
||||
gizmos.GizmoUnjoin,
|
||||
gizmos.GizmoExtend,
|
||||
gizmos.GizmoExtendVertical,
|
||||
gizmos.GizmoOffsetExterior,
|
||||
gizmos.GizmoOffsetCenter,
|
||||
gizmos.GizmoOffsetInterior,
|
||||
gizmos.GizmoAddOpening,
|
||||
gizmos.GizmoCycle,
|
||||
gizmos.GizmoMenu,
|
||||
# Drawing-specific gizmos
|
||||
gizmos.UglyDotGizmo,
|
||||
gizmos.ExtrusionGuidesGizmo,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,9 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
|
||||
if camera.type != props.camera_type:
|
||||
camera.type = props.camera_type
|
||||
|
||||
if props.update_props and (drawing := tool.Ifc.get_entity(camera_obj)):
|
||||
tool.Drawing.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
ortho_scale, aspect_ratio = props.get_scale_and_aspect_ratio()
|
||||
scene_render = scene.render
|
||||
if (camera.ortho_scale != ortho_scale) or not tool.Cad.is_x(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -57,7 +57,7 @@ import shapely
|
||||
from bpy_extras.image_utils import load_image
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from lxml import etree
|
||||
from mathutils import Color, Vector
|
||||
from mathutils import Color, Matrix, Vector
|
||||
|
||||
import bonsai.bim.export_ifc
|
||||
import bonsai.bim.handler
|
||||
@@ -602,6 +602,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
context_type: Literal["body", "annotation"],
|
||||
drawing_elements: set[ifcopenshell.entity_instance],
|
||||
target_view: str,
|
||||
link_matrix: Optional[Matrix] = None,
|
||||
) -> None:
|
||||
drawing_elements = drawing_elements.copy()
|
||||
contexts_: list[list[int]] = getattr(contexts, context_type)
|
||||
@@ -613,9 +614,19 @@ class CreateDrawing(bpy.types.Operator):
|
||||
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
|
||||
|
||||
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
|
||||
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
|
||||
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
|
||||
|
||||
if link_matrix is not None:
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
|
||||
t = link_matrix.to_translation()
|
||||
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
|
||||
geom_settings.set("model-offset", offset)
|
||||
q = link_matrix.to_quaternion()
|
||||
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
|
||||
elif z_offset:
|
||||
# A 2mm Z offset to combat Z-fighting in plan or RCPs
|
||||
geom_settings.set("model-offset", (0.0, 0.0, 0.002 if target_view == "PLAN_VIEW" else -0.002))
|
||||
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
|
||||
|
||||
geom_settings.set("context-ids", context)
|
||||
it = ifcopenshell.geom.iterator(
|
||||
@@ -923,11 +934,16 @@ class CreateDrawing(bpy.types.Operator):
|
||||
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
files = {bim_props.ifc_file: tool.Ifc.get()}
|
||||
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
|
||||
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
for link in props.get_loaded_links_for_drawings():
|
||||
files[link.filepath] = self.get_linked_file(link)
|
||||
try:
|
||||
link_matrix = tool.Project.calculate_link_matrix(link)
|
||||
except Exception:
|
||||
link_matrix = None
|
||||
files[link.filepath] = (self.get_linked_file(link), link_matrix)
|
||||
|
||||
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
|
||||
self.setup_serialiser(target_view)
|
||||
@@ -935,7 +951,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
tree = ifcopenshell.geom.tree()
|
||||
tree.enable_face_styles(True)
|
||||
|
||||
for ifc_path, ifc in files.items():
|
||||
for ifc_path, (ifc, link_matrix) in files.items():
|
||||
# Don't use draw.main() just whilst we're prototyping and experimenting
|
||||
# TODO: hash paths are never used
|
||||
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
|
||||
@@ -949,8 +965,8 @@ class CreateDrawing(bpy.types.Operator):
|
||||
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
|
||||
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
|
||||
contexts = self.get_linework_contexts(ifc, target_view)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix)
|
||||
|
||||
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
|
||||
with profile("Camera element"):
|
||||
@@ -2319,7 +2335,8 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
bl_description = (
|
||||
"Activates the selected drawing view.\n\n"
|
||||
+ "ALT+CLICK to keep the viewport position.\n\n"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
|
||||
)
|
||||
|
||||
drawing: bpy.props.IntProperty()
|
||||
@@ -2335,13 +2352,23 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
load_selected_annotations: bpy.props.BoolProperty(
|
||||
name="Load Selected Annotations",
|
||||
description="Load the annotations of all selected drawings without switching the active view.",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
drawing: int
|
||||
should_view_from_camera: bool
|
||||
use_quick_preview: bool
|
||||
load_selected_annotations: bool
|
||||
|
||||
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
|
||||
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
|
||||
self.load_selected_annotations = True
|
||||
return self.execute(context)
|
||||
if event.type == "LEFTMOUSE" and event.alt:
|
||||
self.should_view_from_camera = False
|
||||
if event.type == "LEFTMOUSE" and event.shift:
|
||||
@@ -2354,6 +2381,18 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
if props.is_editing_drawings == False:
|
||||
bpy.ops.bim.load_drawings()
|
||||
|
||||
if self.load_selected_annotations:
|
||||
for d in props.drawings:
|
||||
if not (d.is_drawing and d.is_selected):
|
||||
continue
|
||||
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
|
||||
# Importing the camera (if missing) ensures the drawing's
|
||||
# collection exists so the annotations get collected into it.
|
||||
if not tool.Ifc.get_object(selected_drawing):
|
||||
tool.Drawing.import_drawing(selected_drawing)
|
||||
tool.Drawing.import_annotations_in_group(tool.Drawing.get_drawing_group(selected_drawing))
|
||||
return {"FINISHED"}
|
||||
|
||||
drawing = tool.Ifc.get().by_id(self.drawing)
|
||||
dprops = tool.Drawing.get_document_props()
|
||||
|
||||
@@ -2439,7 +2478,8 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
|
||||
bl_description = (
|
||||
"Activates the selected drawing view.\n\n"
|
||||
+ "ALT+CLICK to keep the viewport position.\n\n"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -604,6 +604,7 @@ class BIMCameraProperties(PropertyGroup):
|
||||
return tool.Blender.get_active_uilist_element(dprops.drawing_styles, self.active_drawing_style_index)
|
||||
|
||||
# For now, this JSON dump are all the parameters that determine a camera's "Block representation"
|
||||
# Perspective camera shift is stored in EPset_Drawing and intentionally excluded here.
|
||||
# By checking this, you will know whether or not the camera IFC representation needs to be refreshed
|
||||
def update_representation(self, matrix_world: Matrix) -> bool:
|
||||
"""Update ``representation`` based on current camera properties and the provided world matrix.
|
||||
|
||||
@@ -99,6 +99,10 @@ class BIM_PT_camera(Panel):
|
||||
if props.target_view == "MODEL_VIEW":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "camera_type")
|
||||
if props.camera_type == "PERSP":
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(camera_data, "shift_x", text="Camera Shift X/Y:")
|
||||
row.prop(camera_data, "shift_y", text="")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "linework_mode")
|
||||
|
||||
@@ -63,12 +63,14 @@ classes = (
|
||||
operator.OverrideOriginSet,
|
||||
operator.OverrideOutlinerDelete,
|
||||
operator.OverridePasteBuffer,
|
||||
operator.PromoteRepresentationToType,
|
||||
operator.PurgeUnusedRepresentations,
|
||||
operator.RefreshLinkedAggregate,
|
||||
operator.RemoveConnection,
|
||||
operator.RemoveRepresentation,
|
||||
operator.RemoveRepresentationItem,
|
||||
operator.RemoveRepresentationItemFromShapeAspect,
|
||||
operator.SelectByRepresentationType,
|
||||
operator.SelectConnection,
|
||||
operator.SelectRepresentationItem,
|
||||
operator.SwitchRepresentation,
|
||||
|
||||
@@ -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:
|
||||
@@ -76,9 +80,9 @@ class ViewportData:
|
||||
modes.append(edit_mode)
|
||||
elif element.is_a("IfcGridAxis"):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
modes.append(edit_mode)
|
||||
elif item_mode not in modes:
|
||||
modes.append(item_mode)
|
||||
@@ -98,12 +102,32 @@ class RepresentationsData:
|
||||
def load(cls):
|
||||
cls.data = {"representations": cls.representations()}
|
||||
cls.data["contexts"] = cls.contexts()
|
||||
cls.data["element_is_type"] = cls.element_is_type()
|
||||
cls.data["element_has_type"] = cls.element_has_type()
|
||||
|
||||
# Only after cls.representations().
|
||||
cls.data["shape_aspects"] = cls.shape_aspects()
|
||||
|
||||
cls.is_loaded = True
|
||||
|
||||
@classmethod
|
||||
def element_is_type(cls) -> bool:
|
||||
obj = tool.Geometry.get_active_or_representation_obj()
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return bool(element and element.is_a("IfcTypeProduct"))
|
||||
|
||||
@classmethod
|
||||
def element_has_type(cls) -> bool:
|
||||
"""True when the active element is an occurrence with a type — i.e. a
|
||||
local representation could be promoted onto that type."""
|
||||
obj = tool.Geometry.get_active_or_representation_obj()
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or element.is_a("IfcTypeProduct"):
|
||||
return False
|
||||
return bool(ifcopenshell.util.element.get_type(element))
|
||||
|
||||
@classmethod
|
||||
def representations(cls) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
@@ -118,11 +142,8 @@ class RepresentationsData:
|
||||
active_representation_id = active_representation.id()
|
||||
|
||||
for representation in tool.Geometry.get_representations_iter(element):
|
||||
representation_type = representation.RepresentationType
|
||||
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
|
||||
if resolved_representation != representation:
|
||||
representation_type = resolved_representation.RepresentationType + "*"
|
||||
representation_type = resolved_representation.RepresentationType
|
||||
|
||||
is_active = (
|
||||
representation.id() == active_representation_id
|
||||
@@ -134,8 +155,17 @@ class RepresentationsData:
|
||||
"ContextType": representation.ContextOfItems.ContextType or "",
|
||||
"ContextIdentifier": "",
|
||||
"TargetView": "",
|
||||
# The representation's own identifier (e.g. 'Body', 'Reference'), which is
|
||||
# distinct from the subcontext's ContextIdentifier above. Two reps can share
|
||||
# one context (e.g. a Body body and a Reference opening template), so showing
|
||||
# this lets them be told apart in the panel.
|
||||
"RepresentationIdentifier": representation.RepresentationIdentifier or "",
|
||||
"RepresentationType": representation_type or "",
|
||||
"is_active": is_active,
|
||||
# True when this representation is an IfcMappedItem resolving to
|
||||
# the type's MappedRepresentation, i.e. inherited from the type
|
||||
# rather than local to the occurrence.
|
||||
"is_mapped": resolved_representation != representation,
|
||||
}
|
||||
if representation.ContextOfItems.is_a("IfcGeometricRepresentationSubContext"):
|
||||
data["ContextIdentifier"] = representation.ContextOfItems.ContextIdentifier or ""
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
import gpu
|
||||
@@ -25,15 +23,16 @@ import ifcopenshell
|
||||
import numpy as np
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class ItemDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
class ItemDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw", "POST_VIEW"),
|
||||
)
|
||||
objs: dict[str, dict[str, list]]
|
||||
obj_is_selected: dict[str, bool]
|
||||
obj_is_boolean: dict[str, list[ifcopenshell.entity_instance]]
|
||||
@@ -119,23 +118,6 @@ class ItemDecorator:
|
||||
"special_edges": special_edges,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_text(self, context):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
selected_elements_color = self.addon_prefs.decorator_color_selected
|
||||
@@ -163,11 +145,6 @@ class ItemDecorator:
|
||||
blf.disable(font_id, blf.SHADOW)
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
def transparent_color(color: Sequence[float], alpha: float = 0.05) -> list[float]:
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
selected_elements_color = self.addon_prefs.decorator_color_selected
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
@@ -197,15 +174,33 @@ class ItemDecorator:
|
||||
if context.mode != "OBJECT":
|
||||
continue
|
||||
self.draw_batch("LINES", data["verts"], selected_elements_color, data["edges"])
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(selected_elements_color), data["tris"])
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(selected_elements_color, alpha=0.05),
|
||||
data["tris"],
|
||||
)
|
||||
self.draw_batch("LINES", data["special_verts"], selected_elements_color, data["special_edges"])
|
||||
elif self.obj_is_boolean[obj_name]:
|
||||
self.draw_batch("LINES", data["verts"], special_elements_color, data["edges"])
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
|
||||
data["tris"],
|
||||
)
|
||||
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
|
||||
else:
|
||||
self.draw_batch(
|
||||
"LINES", data["verts"], transparent_color(unselected_elements_color, alpha=0.2), data["edges"]
|
||||
"LINES",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(unselected_elements_color, alpha=0.2),
|
||||
data["edges"],
|
||||
)
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
data["verts"],
|
||||
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
|
||||
data["tris"],
|
||||
)
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
|
||||
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
|
||||
|
||||
@@ -60,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:
|
||||
@@ -417,6 +418,55 @@ class SelectConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.select_connection(tool.Geometry, connection=tool.Ifc.get().by_id(self.connection))
|
||||
|
||||
|
||||
class SelectByRepresentationType(bpy.types.Operator):
|
||||
bl_idname = "bim.select_by_representation_type"
|
||||
bl_label = "Select By Representation Type"
|
||||
bl_description = (
|
||||
"Select objects whose active representation matches this type. "
|
||||
"Ctrl+Click to also include objects that have this type in any representation (active or not)"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
representation_type: bpy.props.StringProperty()
|
||||
select_inactive: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.select_inactive = event.ctrl
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
ifc = tool.Ifc.get()
|
||||
if not ifc:
|
||||
return {"CANCELLED"}
|
||||
# Strip the "*" suffix used for mapped/resolved representations.
|
||||
target_type = self.representation_type.rstrip("*")
|
||||
matched = 0
|
||||
for obj in context.visible_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
if self.select_inactive:
|
||||
# Ctrl: match any representation on the element, active or not.
|
||||
has_type = any(
|
||||
(ifcopenshell.util.representation.resolve_representation(rep).RepresentationType or "") == target_type
|
||||
for rep in ifcopenshell.util.representation.get_representations_iter(element)
|
||||
)
|
||||
else:
|
||||
# Default: match only the currently active (displayed) representation.
|
||||
active_rep = tool.Geometry.get_active_representation(obj)
|
||||
if active_rep is None:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
resolved = ifcopenshell.util.representation.resolve_representation(active_rep)
|
||||
has_type = (resolved.RepresentationType or "") == target_type
|
||||
obj.select_set(has_type)
|
||||
if has_type:
|
||||
matched += 1
|
||||
mode = "any representation" if self.select_inactive else "active representation"
|
||||
self.report({"INFO"}, f"Selected {matched} object(s) with RepresentationType '{target_type}' ({mode})")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_connection"
|
||||
bl_label = "Remove Connection"
|
||||
@@ -495,6 +545,36 @@ class RemoveRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
self.report({"INFO"}, f"{self.bl_label} was finished in {operator_time:.2f} seconds.")
|
||||
|
||||
|
||||
class PromoteRepresentationToType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.promote_representation_to_type"
|
||||
bl_label = "Promote Representation to Type"
|
||||
bl_description = (
|
||||
"Move this occurrence-local representation onto its type so occurrences can inherit it.\n"
|
||||
"Occurrences with an identical local representation inherit from the type;\n"
|
||||
"occurrences with a divergent local representation keep their own override"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
representation_id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
representation_id: int
|
||||
|
||||
def _execute(self, context):
|
||||
assert context.active_object
|
||||
counts = core.promote_representation_to_type(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=context.active_object,
|
||||
representation=tool.Ifc.get().by_id(self.representation_id),
|
||||
)
|
||||
self.report(
|
||||
{"INFO"},
|
||||
"Representation promoted to type. "
|
||||
f"{counts['occurrences']} occurrence(s) now inherit it "
|
||||
f"({counts['replaced']} local representation(s) replaced).",
|
||||
)
|
||||
|
||||
|
||||
class PurgeUnusedRepresentations(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.purge_unused_representations"
|
||||
bl_label = "Purge Unused Representations"
|
||||
@@ -545,6 +625,13 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
objs = [bpy.data.objects[obj_name]] if obj_name else context.selected_objects
|
||||
self.file = tool.Ifc.get()
|
||||
|
||||
# Tessellated face sets (IfcTriangulatedFaceSet/IfcPolygonalFaceSet) were
|
||||
# introduced in IFC4 and do not exist in IFC2X3. Catch this early so we
|
||||
# don't silently fall back to a faceted brep after stripping materials.
|
||||
if self.ifc_representation_class == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
|
||||
self.report({"ERROR"}, "Tessellated face sets are not supported in IFC2X3.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
for obj in objs:
|
||||
# TODO: write unit tests to see how this bulk operation handles
|
||||
# contradictory ifc_representation_class values and when
|
||||
@@ -702,6 +789,16 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if mprops.ifc_parameters:
|
||||
core.get_representation_ifc_parameters(tool.Geometry, obj=obj)
|
||||
|
||||
# Persist an edited opening void onto its filling type's 'Reference' template so the
|
||||
# change survives type duplication/append/switching and propagates to siblings. This
|
||||
# catches the edited_objs commit path; the in-place item edit is caught in
|
||||
# bim.override_mode_set_object.
|
||||
edited_element = tool.Ifc.get_entity(obj)
|
||||
if edited_element and edited_element.is_a("IfcOpeningElement"):
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
FilledOpeningGenerator().update_type_template_from_opening(edited_element)
|
||||
|
||||
|
||||
class UpdateParametricRepresentation(bpy.types.Operator):
|
||||
bl_idname = "bim.update_parametric_representation"
|
||||
@@ -882,6 +979,16 @@ class OverrideDelete(bpy.types.Operator):
|
||||
# Track aggregates before deleting their parts
|
||||
aggregates_to_check = self.track_aggregates(objects_to_remove)
|
||||
|
||||
# Snapshot the set of IFC entity ids being deleted in this batch so the
|
||||
# connection-rel cascade inside `delete_ifc_object` can suppress
|
||||
# partner-side regenerate when the partner is also about to vanish.
|
||||
batch_being_deleted_ids: set[int] = set()
|
||||
for obj in objects_to_remove:
|
||||
if not tool.Blender.is_valid_data_block(obj):
|
||||
continue
|
||||
if (entity := tool.Ifc.get_entity(obj)) is not None:
|
||||
batch_being_deleted_ids.add(entity.id())
|
||||
|
||||
clear_active_object = True
|
||||
|
||||
for i, obj in enumerate(objects_to_remove, 1):
|
||||
@@ -923,7 +1030,7 @@ class OverrideDelete(bpy.types.Operator):
|
||||
if tool.Drawing.is_auto_annotation(element):
|
||||
self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.")
|
||||
continue
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids)
|
||||
elif tool.Geometry.is_representation_item(obj):
|
||||
tool.Geometry.delete_ifc_item(obj)
|
||||
else:
|
||||
@@ -1022,14 +1129,17 @@ class OverrideDelete(bpy.types.Operator):
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
continue
|
||||
array_parents.add(ifc_file.by_guid(pset["Parent"]))
|
||||
try:
|
||||
array_parents.add(ifc_file.by_guid(pset["Parent"]))
|
||||
except RuntimeError:
|
||||
continue
|
||||
|
||||
for array_parent in array_parents:
|
||||
array_parent_obj = tool.Ifc.get_object(array_parent)
|
||||
data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.Array.get_modifiers_data(array_parent))]
|
||||
data = [(i, data) for i, data in enumerate(tool.Array.get_modifiers_data(array_parent))]
|
||||
# NOTE: there is a way to remove arrays more precisely but it's more complex
|
||||
for i, modifier_data in reversed(data):
|
||||
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
|
||||
children = set(tool.Array.get_children_objects(modifier_data))
|
||||
if children.issubset(selected_objects):
|
||||
with context.temp_override(active_object=array_parent_obj):
|
||||
bpy.ops.bim.remove_array(item=i)
|
||||
@@ -1183,7 +1293,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 +1397,9 @@ 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 +2336,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 +2379,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 +2467,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 +2476,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)
|
||||
@@ -2459,6 +2578,15 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return bpy.ops.bim.edit_boundary_geometry()
|
||||
elif tool.Geometry.is_representation_item(context.active_object):
|
||||
self.edit_representation_item(context.active_object)
|
||||
# If we just edited an opening's void item, persist the new shape onto the
|
||||
# filling type's 'Reference' template so it survives type duplication/append/
|
||||
# switching and propagates to siblings.
|
||||
rep_obj = tool.Geometry.get_geometry_props().representation_obj
|
||||
edited_element = tool.Ifc.get_entity(rep_obj) if rep_obj else None
|
||||
if edited_element and edited_element.is_a("IfcOpeningElement"):
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
FilledOpeningGenerator().update_type_template_from_opening(edited_element)
|
||||
tool.Root.reload_item_decorator()
|
||||
# So you can keep hitting tab to cycle out of edit mode
|
||||
context.active_object.select_set(False)
|
||||
@@ -2485,9 +2613,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
profile = tool.Ifc.get().by_id(profile_id)
|
||||
if tool.Ifc.get_object(profile): # We are editing an arbitrary profile
|
||||
bpy.ops.bim.edit_arbitrary_profile()
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
bpy.ops.bim.finish_editing_railing_path()
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
bpy.ops.bim.finish_editing_roof_path()
|
||||
elif tool.Model.get_usage_type(element) == "PROFILE":
|
||||
bpy.ops.bim.edit_extrusion_axis()
|
||||
@@ -3156,7 +3284,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
|
||||
product_reps = element.RepresentationMaps
|
||||
item_aspect = {}
|
||||
for product_rep in product_reps:
|
||||
for aspect in product_rep.HasShapeAspects:
|
||||
for aspect in getattr(product_rep, "HasShapeAspects", ()):
|
||||
for aspect_rep in aspect.ShapeRepresentations:
|
||||
if aspect_rep.ContextOfItems != representation.ContextOfItems:
|
||||
continue
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import bonsai.bim
|
||||
@@ -147,12 +148,33 @@ class BIM_PT_representations(Panel):
|
||||
self.layout.label(text="No Representations Found")
|
||||
return
|
||||
|
||||
for representation in RepresentationsData.data["representations"]:
|
||||
header = self.layout.row(align=True)
|
||||
header.label(text="Context")
|
||||
header.label(text="Subcontext")
|
||||
header.label(text="View")
|
||||
header.label(text="Identifier")
|
||||
header.label(text="Type")
|
||||
# Blank icon cells reserve the same width as the switch/remove buttons below so the
|
||||
# text columns line up with the data rows.
|
||||
header.label(text="", icon="BLANK1")
|
||||
header.label(text="", icon="BLANK1")
|
||||
|
||||
def draw_representation_row(representation, allow_promote=False):
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text=representation["ContextType"])
|
||||
row.label(text=representation["ContextIdentifier"])
|
||||
row.label(text=representation["TargetView"])
|
||||
row.label(text=representation["RepresentationType"])
|
||||
row.label(text=representation["RepresentationIdentifier"])
|
||||
op = row.operator(
|
||||
"bim.select_by_representation_type",
|
||||
text=representation["RepresentationType"],
|
||||
emboss=False,
|
||||
)
|
||||
op.representation_type = representation["RepresentationType"]
|
||||
if allow_promote:
|
||||
row.operator(
|
||||
"bim.promote_representation_to_type", icon="EXPORT", text=""
|
||||
).representation_id = representation["id"]
|
||||
op = row.operator(
|
||||
"bim.switch_representation",
|
||||
icon="FILE_REFRESH" if representation["is_active"] else "OUTLINER_DATA_MESH",
|
||||
@@ -162,6 +184,28 @@ class BIM_PT_representations(Panel):
|
||||
op.disable_opening_subtractions = False
|
||||
row.operator("bim.remove_representation", icon="X", text="").representation_id = representation["id"]
|
||||
|
||||
representations = RepresentationsData.data["representations"]
|
||||
# For a type element every representation is its own; for an occurrence
|
||||
# split them into those inherited from the type (mapped) vs those local
|
||||
# to the occurrence, so it's clear which are driven by the type.
|
||||
if RepresentationsData.data["element_is_type"]:
|
||||
for representation in representations:
|
||||
draw_representation_row(representation)
|
||||
else:
|
||||
type_representations = [r for r in representations if r["is_mapped"]]
|
||||
occurrence_representations = [r for r in representations if not r["is_mapped"]]
|
||||
# A local representation can be promoted onto the type only when the
|
||||
# occurrence actually has a type to promote it onto.
|
||||
allow_promote = RepresentationsData.data["element_has_type"]
|
||||
if type_representations:
|
||||
self.layout.label(text="Type", icon="LINKED")
|
||||
for representation in type_representations:
|
||||
draw_representation_row(representation)
|
||||
if occurrence_representations:
|
||||
self.layout.label(text="Occurrence", icon="OBJECT_DATA")
|
||||
for representation in occurrence_representations:
|
||||
draw_representation_row(representation, allow_promote=allow_promote)
|
||||
|
||||
# Presentation layers.
|
||||
self.layout.separator()
|
||||
if not LayersData.is_loaded:
|
||||
@@ -483,10 +527,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)
|
||||
|
||||
@@ -21,7 +21,6 @@ from math import radians
|
||||
import blf
|
||||
import gpu
|
||||
import ifcopenshell.util.geolocation
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Matrix, Vector
|
||||
@@ -30,27 +29,11 @@ import bonsai.tool as tool
|
||||
from bonsai.bim.module.georeference.data import GeoreferenceData
|
||||
|
||||
|
||||
class GeoreferenceDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
class GeoreferenceDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw_geometry", "POST_VIEW"),
|
||||
)
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
@@ -197,6 +180,10 @@ class GeoreferenceDecorator:
|
||||
decorator_color_error = self.addon_prefs.decorator_color_error
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
# The georef gizmo is a coordinate-system overlay: it must communicate
|
||||
# orientation regardless of model contents, so depth testing is bypassed.
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
gpu.state.depth_test_set("ALWAYS")
|
||||
|
||||
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
@@ -335,6 +322,8 @@ class GeoreferenceDecorator:
|
||||
self.draw_batch("LINES", verts, decorator_color_special, edges)
|
||||
self.draw_dashed_line(location * 3, location * 6, decorator_color_error)
|
||||
|
||||
gpu.state.depth_test_set(original_depth_test)
|
||||
|
||||
def draw_dashed_line(self, start, end, colour, should_scale=True):
|
||||
direction = (end - start).normalized()
|
||||
distance = (end - start).length
|
||||
|
||||
@@ -20,44 +20,18 @@
|
||||
import blf
|
||||
import bpy
|
||||
import gpu
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.light.data import SolarData
|
||||
|
||||
|
||||
class SolarDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
class SolarDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw_geometry", "POST_VIEW"),
|
||||
)
|
||||
|
||||
def draw_text(self, context: bpy.types.Context) -> None:
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
@@ -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,
|
||||
@@ -27,7 +31,9 @@ from . import (
|
||||
external,
|
||||
grid,
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
mep_bend_preview,
|
||||
opening,
|
||||
product,
|
||||
profile,
|
||||
@@ -46,17 +52,28 @@ from . import (
|
||||
|
||||
classes = (
|
||||
array.AddArray,
|
||||
array.DisableEditingArray,
|
||||
array.EditArray,
|
||||
array.CancelEditingArray,
|
||||
array.EnableEditingArray,
|
||||
array.FinishEditingArray,
|
||||
array.ApplyArray,
|
||||
array.RegenerateArray,
|
||||
array.RemoveArray,
|
||||
array.SelectAllArrayObjects,
|
||||
array.SelectArrayParent,
|
||||
array.ArrayParentGizmoClick,
|
||||
array.EditArrayFromChild,
|
||||
array.Input3DCursorXArray,
|
||||
array.Input3DCursorYArray,
|
||||
array.Input3DCursorZArray,
|
||||
array.EnableEditingParametric,
|
||||
array.AddArrayFromFeatureEdit,
|
||||
array.ArrayGizmoClick,
|
||||
array.ToggleArrayMethod,
|
||||
array.RemoveArrayLayerFromEdit,
|
||||
array.InputArrayCount,
|
||||
array.AdjustArrayCount,
|
||||
array.GizmoArrayEdition,
|
||||
array.GizmoArrayChild,
|
||||
product.AddDefaultType,
|
||||
product.AddEmptyType,
|
||||
product.AddOccurrence,
|
||||
@@ -68,21 +85,51 @@ classes = (
|
||||
product.SetActiveType,
|
||||
workspace.Hotkey,
|
||||
workspace.BIM_MT_add_representation_item,
|
||||
wall.AddPerpendicularWall,
|
||||
wall.AddWallsFromSlab,
|
||||
wall.AlignWall,
|
||||
wall.CancelEditingWall,
|
||||
wall.ChangeExtrusionDepth,
|
||||
wall.ChangeExtrusionXAngle,
|
||||
wall.ChangeLayerLength,
|
||||
wall.CycleWallOffset,
|
||||
wall.DrawPolylineWall,
|
||||
wall.EnableEditingWall,
|
||||
wall.ExtendWallHeightToCursor,
|
||||
wall.ExtendWallsToUnderside,
|
||||
wall.RegenerateWallToUnderside,
|
||||
wall.ExtendWallsToWall,
|
||||
wall.ExtendWallsToPolylinePoint,
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
host_add_opening_gizmo.GizmoHostAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostToggleOpenings,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoPairDisconnect,
|
||||
wall.GizmoSlabEdition,
|
||||
wall.GizmoSlabUnjoinWalls,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
wall.JoinWallsIntersection,
|
||||
wall.MergeWall,
|
||||
wall.OffsetWalls,
|
||||
wall.RecalculateWall,
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.DisconnectElements,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
wall.CancelWallFilletPreview,
|
||||
wall.EnableWallFilletPreviewFromCorner,
|
||||
wall.CreateWallFillet,
|
||||
opening.AddBoolean,
|
||||
opening.CloneOpening,
|
||||
opening.EditOpenings,
|
||||
@@ -94,6 +141,7 @@ classes = (
|
||||
opening.RemoveBoolean,
|
||||
opening.SelectBoolean,
|
||||
opening.ShowOpenings,
|
||||
opening.ToggleHostOpenings,
|
||||
opening.UpdateOpeningsFocus,
|
||||
profile.ChangeCardinalPoint,
|
||||
profile.ChangeProfileDepth,
|
||||
@@ -109,11 +157,14 @@ classes = (
|
||||
slab.DisableEditingExtrusionProfile,
|
||||
slab.DisableEditingSketchExtrusionProfile,
|
||||
slab.AddSlabFromWall,
|
||||
slab.CancelEditingSlab,
|
||||
slab.DrawPolylineSlab,
|
||||
slab.EditExtrusionProfile,
|
||||
slab.EditSketchExtrusionProfile,
|
||||
slab.EnableEditingExtrusionProfile,
|
||||
slab.EnableEditingSketchExtrusionProfile,
|
||||
slab.EnableEditingSlab,
|
||||
slab.FinishEditingSlab,
|
||||
slab.RecalculateSlab,
|
||||
slab.ResetVertex,
|
||||
slab.SetArcIndex,
|
||||
@@ -140,10 +191,19 @@ classes = (
|
||||
prop.BIMDoorProperties,
|
||||
prop.BIMRailingProperties,
|
||||
prop.BIMRoofProperties,
|
||||
prop.BIMSlabProperties,
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPipeSegmentProperties,
|
||||
prop.BIMDuctSegmentProperties,
|
||||
prop.BIMPolylineProperties,
|
||||
prop.BIMExternalParametricGeometryProperties,
|
||||
prop.BIMBendPreviewProperties,
|
||||
prop.BIMWallFilletPreviewProperties,
|
||||
prop.BIMPreviewProperties,
|
||||
prop.BIMParametricEditDialogPrefs,
|
||||
ui.BIM_PT_array,
|
||||
ui.BIM_PT_stair,
|
||||
ui.BIM_PT_wall,
|
||||
ui.BIM_PT_sverchok,
|
||||
ui.BIM_PT_window,
|
||||
ui.BIM_PT_door,
|
||||
@@ -164,7 +224,8 @@ classes = (
|
||||
stair.ToggleStairProperty,
|
||||
stair.AdjustStairTreads,
|
||||
stair.SetStairTreads,
|
||||
stair.CycleStairType,
|
||||
stair.InputStairTreads,
|
||||
stair.PickStairType,
|
||||
stair.GizmoStairEdition,
|
||||
sverchok_modifier.CreateNewSverchokGraph,
|
||||
sverchok_modifier.UpdateDataFromSverchok,
|
||||
@@ -177,7 +238,7 @@ classes = (
|
||||
window.FinishEditingWindow,
|
||||
window.EnableEditingWindow,
|
||||
window.RemoveWindow,
|
||||
window.CycleWindowType,
|
||||
window.PickWindowType,
|
||||
window.GizmoWindowEdition,
|
||||
door.BIM_OT_add_door,
|
||||
door.AddDoor,
|
||||
@@ -186,15 +247,19 @@ classes = (
|
||||
door.EnableEditingDoor,
|
||||
door.RemoveDoor,
|
||||
door.ToggleDoorSwing,
|
||||
door.CycleDoorType,
|
||||
door.PickDoorType,
|
||||
door.GizmoDoorEdition,
|
||||
railing.BIM_OT_add_railing,
|
||||
railing.CopyRailingParameters,
|
||||
railing.AddRailing,
|
||||
railing.CancelEditingRailing,
|
||||
railing.CycleRailingType,
|
||||
railing.FinishEditingRailing,
|
||||
railing.PickRailingTerminalType,
|
||||
railing.FlipRailingPathOrder,
|
||||
railing.EnableEditingRailing,
|
||||
railing.GizmoRailingSchematic,
|
||||
railing.ToggleRailingUseManualSupports,
|
||||
railing.CancelEditingRailingPath,
|
||||
railing.FinishEditingRailingPath,
|
||||
railing.EnableEditingRailingPath,
|
||||
@@ -203,16 +268,39 @@ classes = (
|
||||
roof.AddRoof,
|
||||
roof.CancelEditingRoof,
|
||||
roof.CopyRoofParameters,
|
||||
roof.CycleRoofGenerationMethod,
|
||||
roof.FinishEditingRoof,
|
||||
roof.EnableEditingRoof,
|
||||
roof.CancelEditingRoofPath,
|
||||
roof.FinishEditingRoofPath,
|
||||
roof.EnableEditingRoofPath,
|
||||
roof.GizmoRoofEdition,
|
||||
roof.RemoveRoof,
|
||||
roof.SetGableRoofEdgeAngle,
|
||||
mep.MEPAddObstruction,
|
||||
mep.MEPAddTransition,
|
||||
mep.MEPAddBend,
|
||||
mep.MEPRemoveTerminalFitting,
|
||||
mep.SelectMEPPathMembers,
|
||||
mep.MEPJoinSegments,
|
||||
mep_bend_preview.EnableBendPreview,
|
||||
mep_bend_preview.FinishBendPreview,
|
||||
mep_bend_preview.CancelBendPreview,
|
||||
mep_bend_preview.EnableBendPreviewFromBend,
|
||||
mep_bend_preview.GizmoBendPreview,
|
||||
mep.EnableEditingPipeSegment,
|
||||
mep.FinishEditingPipeSegment,
|
||||
mep.CancelEditingPipeSegment,
|
||||
mep.EnableEditingDuctSegment,
|
||||
mep.FinishEditingDuctSegment,
|
||||
mep.CancelEditingDuctSegment,
|
||||
mep.ExtendPipeSegmentToCursor,
|
||||
mep.ExtendDuctSegmentToCursor,
|
||||
mep.SplitPipeSegmentAtCursor,
|
||||
mep.SplitDuctSegmentAtCursor,
|
||||
mep.GizmoPipeSegmentEdition,
|
||||
mep.GizmoDuctSegmentEdition,
|
||||
mep.GizmoMEPActions,
|
||||
external.ApplyExternalParametricGeometry,
|
||||
)
|
||||
|
||||
@@ -264,15 +352,17 @@ 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.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
|
||||
type=prop.BIMParametricEditDialogPrefs
|
||||
)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
@@ -281,6 +371,17 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
# DecorationsHandler is installed lazily by bim.show_openings; tear it down
|
||||
# (along with its persistent depsgraph / undo / redo / load cache handlers)
|
||||
# before the rest of unregister so those handlers can't fire against
|
||||
# half-unloaded module state.
|
||||
opening.DecorationsHandler.uninstall()
|
||||
|
||||
# Network path overlays attach SpaceView3D draw handlers on toggle;
|
||||
# uninstall here so addon disable / Blender shutdown doesn't leak them.
|
||||
decorator.MEPSystemPathDecorator.uninstall()
|
||||
decorator.WallSystemPathDecorator.uninstall()
|
||||
|
||||
if not bpy.app.background:
|
||||
for tool_data in reversed(tools):
|
||||
bpy.utils.unregister_tool(tool_data.tool)
|
||||
@@ -288,13 +389,11 @@ 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
|
||||
del bpy.types.WindowManager.BIMParametricEditDialogPrefs
|
||||
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,10 @@ class AuthoringData:
|
||||
|
||||
@classmethod
|
||||
def load(cls, ifc_element_type: Optional[str] = None):
|
||||
# ``is_loaded`` is set first as a recursion guard: one of the data
|
||||
# computations evaluates a PropertyGroup enum's ``items`` callback,
|
||||
# which re-enters this method. Without the guard, load recurses to
|
||||
# RecursionError.
|
||||
cls.is_loaded = True
|
||||
cls.props = tool.Model.get_model_props()
|
||||
cls.data["default_container"] = cls.default_container()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,9 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMDoorProperties
|
||||
@@ -566,103 +568,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.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_door_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_door_modifier_representation(obj)
|
||||
|
||||
|
||||
class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
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):
|
||||
@@ -673,7 +630,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def remove_door_on_object(self, obj: bpy.types.Object) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
if not tool.Parametric.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
props.is_editing = False
|
||||
@@ -688,12 +645,8 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
|
||||
class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Toggle door swing direction and optionally flip door geometry.
|
||||
|
||||
Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction"""
|
||||
|
||||
bl_idname = "bim.toggle_door_swing"
|
||||
bl_label = "Toggle Door Swing"
|
||||
bl_label = "Change Door Swing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
|
||||
@@ -704,6 +657,15 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str:
|
||||
if properties.flip_geometry:
|
||||
return (
|
||||
"Swing the door from the opposite side of the wall. "
|
||||
"Shift+click: mirror the door without changing which side it opens to"
|
||||
)
|
||||
return "Move the door hinge to the opposite side"
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
self.skip_direction_change = event.shift
|
||||
return self.execute(context)
|
||||
@@ -730,7 +692,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"CANCELLED"}
|
||||
|
||||
is_door = tool.Blender.Modifier.is_door(element)
|
||||
is_door = tool.Parametric.is_door(element)
|
||||
|
||||
if self.flip_geometry:
|
||||
tool.Geometry.flip_object(obj, self.flip_local_axes)
|
||||
@@ -744,20 +706,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available door types. Shift+click to cycle in reverse."""
|
||||
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a door type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_door_type"
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_idname = "bim.pick_door_type"
|
||||
bl_label = "Pick Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "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"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
@@ -770,7 +732,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_door"
|
||||
finish_editing_operator = "bim.finish_editing_door"
|
||||
cancel_editing_operator = "bim.cancel_editing_door"
|
||||
cycle_type_operator = "bim.cycle_door_type"
|
||||
pick_type_operator = "bim.pick_door_type"
|
||||
|
||||
# Declarative dimension gizmo configuration with visibility and position
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
@@ -877,14 +839,44 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
p.get_transom_window_center_z(),
|
||||
),
|
||||
),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
props_getter = "get_door_props"
|
||||
# Big quarter-arc hit shapes cover much of the door face — without a
|
||||
# negative select_bias they would steal clicks from the small dimension
|
||||
# and edit gizmos drawn on top of them.
|
||||
SWING_ARC_SELECT_BIAS = -1000.0
|
||||
swing_arc_operator = "bim.toggle_door_swing"
|
||||
|
||||
swing_arc_props = [
|
||||
gizmo.SwingArcConfig(
|
||||
name="primary",
|
||||
visibility_condition=lambda p: p.is_editing and "SLIDING" not in p.door_type,
|
||||
hinge_x=lambda p: (
|
||||
p.overall_width if p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type else 0.0
|
||||
),
|
||||
hinge_y=lambda p: p.lining_offset,
|
||||
panel_width=lambda p: p.overall_width / 2 if "DOUBLE_DOOR" in p.door_type else p.overall_width,
|
||||
x_mirror=lambda p: p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type,
|
||||
),
|
||||
gizmo.SwingArcConfig(
|
||||
name="secondary",
|
||||
visibility_condition=lambda p: p.is_editing
|
||||
and "DOUBLE_DOOR" in p.door_type
|
||||
and "SLIDING" not in p.door_type,
|
||||
hinge_x=lambda p: p.overall_width,
|
||||
hinge_y=lambda p: p.lining_offset,
|
||||
panel_width=lambda p: p.overall_width / 2,
|
||||
x_mirror=lambda _p: True,
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_door_props
|
||||
gizmo_pref_name = "door"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for door icon positioning.
|
||||
@@ -902,24 +894,20 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
return (furthest_y, furthest_y)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create door-specific swing arc gizmos."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
inactive_color = prefs.decorator_color_background[:3]
|
||||
special_color = prefs.decorator_color_special[:3]
|
||||
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
|
||||
|
||||
self.gizmo_door_type = self.create_arc_gizmo(
|
||||
special_color,
|
||||
"bim.toggle_door_swing",
|
||||
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",
|
||||
)
|
||||
Stored as ``self.gizmo_swing_arc_<name>`` and ``self.gizmo_swing_arc_<name>_flip``
|
||||
and pinned to ``SWING_ARC_SELECT_BIAS`` so other door gizmos win selection."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
main_color = prefs.decorator_color_special[:3]
|
||||
flip_color = prefs.decorator_color_background[:3]
|
||||
for cfg in self.swing_arc_props:
|
||||
main = self.create_arc_gizmo(main_color, self.swing_arc_operator, flip_geometry=False)
|
||||
flip = self.create_arc_gizmo(flip_color, self.swing_arc_operator, flip_geometry=True)
|
||||
for gz in (main, flip):
|
||||
gz.select_bias = self.SWING_ARC_SELECT_BIAS
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}", main)
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
|
||||
@@ -938,29 +926,23 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self._update_view_dependent_dimensions(context, mw, props)
|
||||
|
||||
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
|
||||
"""Update swing gizmo position and color based on editing state."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
door_gizmo_prefs = prefs.gizmos.door
|
||||
|
||||
door_type_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc
|
||||
)
|
||||
flip_arc_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc
|
||||
)
|
||||
|
||||
if not door_type_visible and not flip_arc_visible:
|
||||
return
|
||||
|
||||
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
|
||||
base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(
|
||||
props.overall_width, 4
|
||||
)
|
||||
|
||||
if door_type_visible:
|
||||
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
|
||||
self.gizmo_door_type.color = prefs.decorations_colour[:3]
|
||||
|
||||
if flip_arc_visible:
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
|
||||
"""Position each declared swing-arc pair per its config + props state."""
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
for cfg in self.swing_arc_props:
|
||||
main = getattr(self, f"gizmo_swing_arc_{cfg.name}")
|
||||
flip = getattr(self, f"gizmo_swing_arc_{cfg.name}_flip")
|
||||
show = cfg.visibility_condition(props)
|
||||
main_visible = self.update_gizmo_visibility(main, show)
|
||||
flip_visible = self.update_gizmo_visibility(flip, show)
|
||||
if not (main_visible or flip_visible):
|
||||
continue
|
||||
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if cfg.x_mirror(props) else Matrix.Identity(4)
|
||||
transform = (
|
||||
Matrix.Translation(V_(cfg.hinge_x(props), cfg.hinge_y(props), 0))
|
||||
@ Matrix.Scale(cfg.panel_width(props), 4)
|
||||
@ x_flip
|
||||
)
|
||||
if main_visible:
|
||||
main.matrix_basis = mw @ transform
|
||||
if flip_visible:
|
||||
flip.matrix_basis = mw @ transform @ mirror_y
|
||||
|
||||
@@ -41,6 +41,12 @@ def load_post(*args):
|
||||
profile.DumbProfileRegenerator().regenerate_from_profile,
|
||||
)
|
||||
|
||||
ifcopenshell.api.add_pre_listener(
|
||||
"type.assign_type",
|
||||
"Bonsai.Opening.PreserveOnTypeChange",
|
||||
opening.FilledOpeningGenerator().preserve_opening_on_type_change,
|
||||
)
|
||||
|
||||
ifcopenshell.api.add_post_listener(
|
||||
"type.assign_type",
|
||||
"Bonsai.Opening.RegenerateFromType",
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Generic single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
|
||||
|
||||
One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
|
||||
parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
|
||||
guards host-host pairings so this gizmo never overlaps with the existing
|
||||
wall-join / extend-vertically gizmos. The positioner dispatches on element
|
||||
type — walls use axis-projection + camera-facing-Y math (which requires the
|
||||
parametric layer-set); slabs and roofs use a world-Z face bias driven by
|
||||
the void object's elevation against the host's bounding box."""
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model.opening import is_filling_supported
|
||||
from bonsai.bim.module.model.wall import (
|
||||
_get_wall_geom_cached,
|
||||
_wall_camera_facing_icon_y,
|
||||
_wall_gizmo_poll_gate,
|
||||
_WallGeomCachedBillboardingMixin,
|
||||
)
|
||||
|
||||
|
||||
def is_supported_host(element) -> bool:
|
||||
"""Total predicate (None → False). Walls accept either a parametric
|
||||
LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
|
||||
layer-set for the anchor math); slabs and roofs only need the bound
|
||||
box so any IfcSlab / IfcRoof qualifies regardless of parametric
|
||||
modifier state."""
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
||||
|
||||
|
||||
def is_supported_filling_or_opening(element) -> bool:
|
||||
"""Total predicate for the add-opening gizmo poll. ``None`` (raw Blender
|
||||
mesh) is accepted because the operator converts unclassified meshes
|
||||
into ``IfcOpeningElement`` instances. ``IfcOpeningElement`` is accepted
|
||||
because reassigning an existing opening to a new host is a legal path
|
||||
through the operator. Otherwise defer to the generator's own
|
||||
supported-filling predicate."""
|
||||
if element is None:
|
||||
return True
|
||||
if element.is_a("IfcOpeningElement"):
|
||||
return True
|
||||
return is_filling_supported(element)
|
||||
|
||||
|
||||
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
||||
selected + IFC entity lookup + supported-host predicate. Returns the
|
||||
active element on success, ``None`` on any failure — callers chain their
|
||||
feature-specific checks past the early-return."""
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return None
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != n_selected:
|
||||
return None
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if not element or not is_supported_host(element):
|
||||
return None
|
||||
return element
|
||||
|
||||
|
||||
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when exactly two objects are selected and one is a fillable
|
||||
host (wall / slab / roof) while the other is a valid filling (door /
|
||||
window / existing opening, or a plain Blender mesh).
|
||||
|
||||
Selection-order independent: the host role is identified by class, not
|
||||
by active state. The "+" icon anchors on the host's surface regardless
|
||||
of which object was clicked first. The dispatched ``bim.add_opening``
|
||||
operator also handles either order.
|
||||
|
||||
Per-frame positioning keeps the icon facing the camera as the viewport
|
||||
orbits."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_add_opening"
|
||||
bl_label = "Host Add Opening Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return False
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return False
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return False
|
||||
a_element = tool.Ifc.get_entity(selected[0])
|
||||
b_element = tool.Ifc.get_entity(selected[1])
|
||||
return cls._is_apply_opening_pair(a_element, b_element) or cls._is_apply_opening_pair(b_element, a_element)
|
||||
|
||||
@staticmethod
|
||||
def _is_apply_opening_pair(host_element, filling_element) -> bool:
|
||||
"""``host_element`` qualifies as a fillable host AND ``filling_element``
|
||||
qualifies as a filling. Used twice with the operands swapped so the
|
||||
gizmo polls true regardless of which of the two selected objects is
|
||||
active."""
|
||||
if not is_supported_host(host_element):
|
||||
return False
|
||||
if not hasattr(host_element, "HasOpenings"):
|
||||
return False
|
||||
return is_supported_filling_or_opening(filling_element)
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.add_opening_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return
|
||||
a, b = selected[0], selected[1]
|
||||
a_element = tool.Ifc.get_entity(a)
|
||||
b_element = tool.Ifc.get_entity(b)
|
||||
if is_supported_host(a_element):
|
||||
host_obj, host_element, other = a, a_element, b
|
||||
elif is_supported_host(b_element):
|
||||
host_obj, host_element, other = b, b_element, a
|
||||
else:
|
||||
return
|
||||
|
||||
if tool.Parametric.is_path_connectable_wall(host_element):
|
||||
world_pos = wall_anchor(context, self, host_obj, other)
|
||||
else:
|
||||
world_pos = layer3_anchor(host_obj, other)
|
||||
if world_pos is None:
|
||||
return
|
||||
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
|
||||
|
||||
|
||||
def wall_anchor(
|
||||
context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
|
||||
) -> Vector | None:
|
||||
"""World-space anchor for the add-opening icon on a wall host: void origin
|
||||
projected onto the wall reference-line X (clamped to wall extents), lifted to
|
||||
the camera-facing wall-local Y."""
|
||||
geom = _get_wall_geom_cached(group, wall_obj)
|
||||
if not geom:
|
||||
return None
|
||||
mw = wall_obj.matrix_world
|
||||
wall_local = mw.inverted() @ other.matrix_world.translation
|
||||
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
|
||||
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
|
||||
base_world = mw @ Vector((local_x, icon_y, 0.0))
|
||||
top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
|
||||
return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
|
||||
|
||||
|
||||
def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
|
||||
"""World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
|
||||
void's world XY, lifted just above the host's top face. Predictable height
|
||||
regardless of where the void sits vertically — clicking the icon places the
|
||||
opening at the void's XY, and the operator handles the actual cut depth."""
|
||||
bbox = tool.Blender.get_object_world_bounding_box(host_obj)
|
||||
anchor_xy = other.matrix_world.translation.xy
|
||||
top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((anchor_xy.x, anchor_xy.y, top_z))
|
||||
|
||||
|
||||
def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
|
||||
"""Object origin XY, lifted just above the topmost mesh vertex. Tracks
|
||||
the parametric origin (useful reference even when the mesh extends
|
||||
asymmetrically) and the visible top face (stays clear of sloped or
|
||||
stepped bodies)."""
|
||||
origin = host_obj.matrix_world.translation
|
||||
top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((origin.x, origin.y, top_z))
|
||||
|
||||
|
||||
class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Fallback toggle-openings icon for hosts that lack their own
|
||||
parametric-edit toolbar — slabs today, plus any foreign-authored
|
||||
IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
|
||||
poll for it). Walls and parametric roofs already render an idle-row
|
||||
toggle next to the pen and are excluded from this poll.
|
||||
|
||||
When slab parametric-edit lands the slab branch will pen-row-handle
|
||||
its own toggle; updating the exclusion predicate here is the only
|
||||
migration step needed."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
|
||||
bl_label = "Host Toggle Openings Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=1)
|
||||
if element is None:
|
||||
return False
|
||||
if not tool.Geometry.has_openings(element):
|
||||
return False
|
||||
# Skip when a per-feature parametric-edit gizmo already surfaces
|
||||
# an idle-row toggle for this element — walls and parametric roofs
|
||||
# both render their own toggle in the pen row.
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
return False
|
||||
if tool.Parametric.is_roof(element):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.toggle_openings_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
|
||||
host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Bend-preview lifecycle for MEP segment joins.
|
||||
|
||||
Holds the four lifecycle operators (Enable / Finish / Cancel /
|
||||
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
|
||||
tunable dimensions and validate/cancel icons during preview. Draft state
|
||||
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
|
||||
for cross-element previews).
|
||||
|
||||
The geometry math (``compute_bend_preview_polylines``,
|
||||
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
|
||||
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
|
||||
it; this module imports the polyline helper for per-frame gizmo
|
||||
positioning. The GPU lines themselves are drawn by
|
||||
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
|
||||
sibling decorators."""
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.mep import (
|
||||
_is_bend_fitting,
|
||||
_n_mep_selected,
|
||||
cached_compute_bend_preview_polylines,
|
||||
segments_are_parallel,
|
||||
validate_bend_preconditions,
|
||||
)
|
||||
|
||||
|
||||
class EnableBendPreview(bpy.types.Operator):
|
||||
"""Enter bend-preview mode for two selected MEP segments. Populates
|
||||
scene.BIMPreviewProperties.bend with segment IFC ids and default
|
||||
start_length / end_length / radius; no IFC mutation until finish."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview"
|
||||
bl_label = "Enter Bend Preview"
|
||||
bl_description = "Begin tuning bend parameters before committing the bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not _n_mep_selected(2):
|
||||
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
|
||||
return {"CANCELLED"}
|
||||
other = next((o for o in selected if o is not active), None)
|
||||
if other is None:
|
||||
self.report({"ERROR"}, "Two MEP segments must be selected.")
|
||||
return {"CANCELLED"}
|
||||
active_element = tool.Ifc.get_entity(active)
|
||||
other_element = tool.Ifc.get_entity(other)
|
||||
if active_element is None or other_element is None:
|
||||
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
|
||||
return {"CANCELLED"}
|
||||
if segments_are_parallel(active, other):
|
||||
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Pre-check the same preconditions MEPAddBend enforces so the user
|
||||
# sees the rejection here rather than after tuning a doomed preview.
|
||||
precondition_error = validate_bend_preconditions(active_element, other_element)
|
||||
if precondition_error is not None:
|
||||
self.report({"ERROR"}, precondition_error)
|
||||
return {"CANCELLED"}
|
||||
|
||||
preview_base.sync_uncommitted_moves([active, other])
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
# Auto-cancel any prior preview so re-clicking join on a different
|
||||
# pair doesn't silently commit the previous tuning.
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
props.start_segment_id = active_element.id()
|
||||
props.end_segment_id = other_element.id()
|
||||
props.start_length = 0.1
|
||||
props.end_length = 0.1
|
||||
props.radius = 0.2
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishBendPreview(bpy.types.Operator):
|
||||
"""Commit the previewed bend with the tuned parameters and exit preview.
|
||||
|
||||
Preview state survives a failed commit so the user can re-tune without
|
||||
re-selecting."""
|
||||
|
||||
bl_idname = "bim.finish_bend_preview"
|
||||
bl_label = "Apply Bend"
|
||||
bl_description = "Commit the bend with the previewed parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
return preview_base.commit_preview(
|
||||
self,
|
||||
context,
|
||||
"bend",
|
||||
"mep_add_bend",
|
||||
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
|
||||
)
|
||||
|
||||
|
||||
class CancelBendPreview(bpy.types.Operator):
|
||||
"""Exit bend preview without committing."""
|
||||
|
||||
bl_idname = "bim.cancel_bend_preview"
|
||||
bl_label = "Cancel Bend"
|
||||
bl_description = "Discard the previewed bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
preview_base.clear_preview_state(props)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableBendPreviewFromBend(bpy.types.Operator):
|
||||
"""Re-open the bend preview on an existing bend fitting.
|
||||
|
||||
Resolves the two connected segments via the bend's ports +
|
||||
``IfcRelConnectsPorts``, reads parametric values back from the bend's
|
||||
``BBIM_Fitting`` pset, and flags the preview so committing replaces
|
||||
the existing bend in place."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview_from_bend"
|
||||
bl_label = "Edit Bend"
|
||||
bl_description = "Re-open the bend preview to retune an existing bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
cls.poll_message_set("No active object.")
|
||||
return False
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None or not _is_bend_fitting(element):
|
||||
cls.poll_message_set("Active object must be a bend fitting.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
active = context.active_object
|
||||
bend_element = tool.Ifc.get_entity(active)
|
||||
if bend_element is None or not _is_bend_fitting(bend_element):
|
||||
self.report({"ERROR"}, "Active object is not a bend fitting.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
connected_segments: list = []
|
||||
for port in tool.System.get_ports(bend_element):
|
||||
connected_port = tool.System.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
related = tool.System.get_port_relating_element(connected_port)
|
||||
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
|
||||
connected_segments.append(related)
|
||||
|
||||
if len(connected_segments) != 2:
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Read parametric values from the bend type's BBIM_Fitting pset. The
|
||||
# type carries the canonical parameters; querying the occurrence
|
||||
# would force a get_type round-trip and miss user-edited types.
|
||||
bend_type = ifcopenshell.util.element.get_type(bend_element)
|
||||
if bend_type is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
|
||||
return {"CANCELLED"}
|
||||
bend_type_obj = tool.Ifc.get_object(bend_type)
|
||||
if bend_type_obj is None:
|
||||
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
|
||||
return {"CANCELLED"}
|
||||
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
|
||||
if bbim is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
|
||||
return {"CANCELLED"}
|
||||
data = bbim.get("data_dict", {})
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
# Segment order is load-bearing: the bend's lateral sign and z-axis
|
||||
# flip are derived from which segment is "start" vs "end". Re-edit
|
||||
# must reuse the same pairing as the original create so the recreate
|
||||
# lands at the same orientation.
|
||||
start_segment, end_segment = connected_segments
|
||||
props.start_segment_id = start_segment.id()
|
||||
props.end_segment_id = end_segment.id()
|
||||
# Pset values are in IFC native units; scene units come from si_conversion.
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
|
||||
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
|
||||
props.radius = float(data.get("radius", 0.2)) * si_conversion
|
||||
props.editing_bend_id = bend_element.id()
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _bend_preview_segments(context):
|
||||
"""Resolve the two segment objects from the scene-level preview props.
|
||||
|
||||
Re-resolves by IFC id each frame so undo / file reload during preview
|
||||
never dangles a stale bpy reference."""
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None or not props.is_active:
|
||||
return None, None
|
||||
try:
|
||||
start_element = ifc_file.by_id(props.start_segment_id)
|
||||
end_element = ifc_file.by_id(props.end_segment_id)
|
||||
except Exception:
|
||||
return None, None
|
||||
start_obj = tool.Ifc.get_object(start_element) if start_element else None
|
||||
end_obj = tool.Ifc.get_object(end_element) if end_element else None
|
||||
return start_obj, end_obj
|
||||
|
||||
|
||||
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
|
||||
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
|
||||
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
|
||||
draws + drags along local +X by convention."""
|
||||
x = x_direction.normalized()
|
||||
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
|
||||
y = (seed - x * seed.dot(x)).normalized()
|
||||
z = x.cross(y)
|
||||
mat = Matrix.Identity(4)
|
||||
mat[0][:3] = (x.x, y.x, z.x)
|
||||
mat[1][:3] = (x.y, y.y, z.y)
|
||||
mat[2][:3] = (x.z, y.z, z.z)
|
||||
mat.translation = location
|
||||
return mat
|
||||
|
||||
|
||||
class GizmoBendPreview(bpy.types.GizmoGroup):
|
||||
"""Interactive gizmo group for the bend preview flow.
|
||||
|
||||
Three dimension widgets drag start_length / end_length / radius; two
|
||||
icon gizmos commit or cancel. When the geometry is degenerate the
|
||||
dimensions and validate hide but cancel stays visible so the user
|
||||
always has an exit."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_bend_preview"
|
||||
bl_label = "Bend Preview Gizmos"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
ICON_SCALE: ClassVar[float] = 0.375
|
||||
ICON_SPACING_X: ClassVar[float] = 0.4
|
||||
ICON_Z_OFFSET: ClassVar[float] = 1.5
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
preview = getattr(context.scene, "BIMPreviewProperties", None)
|
||||
props = preview.bend if preview is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return False
|
||||
try:
|
||||
ifc_file.by_id(props.start_segment_id)
|
||||
ifc_file.by_id(props.end_segment_id)
|
||||
except (RuntimeError, KeyError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context):
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
default_color = tuple(prefs.decorations_colour[:3])
|
||||
highlight_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
_props = preview_base.make_props_callback("bend")
|
||||
|
||||
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
|
||||
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
|
||||
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
|
||||
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
|
||||
gz.axis = Vector((1, 0, 0))
|
||||
gz.invert_delta = invert_delta
|
||||
gz.delta_scale = 1.0
|
||||
gz.prop_name = prop_name
|
||||
gz.gizmo_group = self
|
||||
gz.color = default_color
|
||||
gz.color_highlight = highlight_color
|
||||
gz.alpha = 1.0
|
||||
gz.use_draw_modal = True
|
||||
gz.use_draw_scale = False
|
||||
gz.text_offset_sign = 1
|
||||
gz.text_alignment = gizmo.TextAlignment.CENTER
|
||||
gz.show_start_arrow = False
|
||||
gz.show_end_arrow = True
|
||||
gz.show_extension_lines = False
|
||||
gz.text_formatter = None
|
||||
return gz
|
||||
|
||||
self.start_dim = setup_dimension("start_length", "Start Length")
|
||||
self.end_dim = setup_dimension("end_length", "End Length")
|
||||
self.radius_dim = setup_dimension("radius", "Radius")
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
|
||||
self.validate_icon.use_draw_scale = False
|
||||
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
|
||||
self.validate_icon.color_highlight = highlight_color
|
||||
self.validate_icon.target_set_operator("bim.finish_bend_preview")
|
||||
|
||||
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
|
||||
self.cancel_icon.use_draw_scale = False
|
||||
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
|
||||
self.cancel_icon.color_highlight = highlight_color
|
||||
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
|
||||
|
||||
def refresh(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def _position_gizmos(self, context):
|
||||
"""Place gizmos at the bend intersection using the current scene
|
||||
props. Cancel stays visible on degenerate geometry so the user
|
||||
always has an exit; the other widgets hide when there's no defined
|
||||
tangent / arc to anchor them on."""
|
||||
start_obj, end_obj = _bend_preview_segments(context)
|
||||
if start_obj is None or end_obj is None:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
preview = cached_compute_bend_preview_polylines(
|
||||
start_obj, end_obj, props.start_length, props.end_length, props.radius
|
||||
)
|
||||
if not preview["valid"]:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
|
||||
gz.hide = True
|
||||
self.cancel_icon.hide = False
|
||||
axes = preview.get("invalid_axes") or []
|
||||
if axes:
|
||||
intersection_point = axes[0][1]
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
return
|
||||
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = False
|
||||
|
||||
leg_a_far, leg_a_end = preview["leg_a"]
|
||||
leg_b_far, leg_b_end = preview["leg_b"]
|
||||
toward_bend_a = (
|
||||
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
toward_bend_b = (
|
||||
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
|
||||
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
|
||||
|
||||
# axis is set in world space every frame so the drag projection
|
||||
# matches the visual regardless of either segment's matrix_world.
|
||||
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
|
||||
self.start_dim.axis = -toward_bend_a
|
||||
self.start_dim.set_dimension_length(props.start_length)
|
||||
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
|
||||
self.end_dim.axis = -toward_bend_b
|
||||
self.end_dim.set_dimension_length(props.end_length)
|
||||
|
||||
arc = preview["arc"]
|
||||
if len(arc) >= 3:
|
||||
mid = len(arc) // 2
|
||||
chord_mid = (arc[0] + arc[-1]) * 0.5
|
||||
toward_mid = arc[mid] - chord_mid
|
||||
if toward_mid.length > 1e-6:
|
||||
toward_mid = toward_mid.normalized()
|
||||
half_chord = (arc[-1] - arc[0]).length * 0.5
|
||||
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
|
||||
arc_center = chord_mid - toward_mid * center_dist
|
||||
radial_out = arc[mid] - arc_center
|
||||
if radial_out.length > 1e-6:
|
||||
radial_out.normalize()
|
||||
inward = -radial_out
|
||||
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
|
||||
self.radius_dim.axis = inward
|
||||
self.radius_dim.set_dimension_length(props.radius)
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
|
||||
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
|
||||
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from math import radians
|
||||
@@ -41,8 +43,211 @@ from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import decorator_cache
|
||||
from bonsai.bim.module.drawing.decoration import DecoratorData
|
||||
|
||||
# Multi-entry cache for the opening preview's dissolved-edges fallback.
|
||||
# Single-entry wouldn't fit: the draw handler iterates every active opening
|
||||
# per frame, each with its own mesh. Bumped wholesale on the shared
|
||||
# decorator-cache token (depsgraph / undo / redo / load), one slot per
|
||||
# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below —
|
||||
# consulted only on world-draw-data miss, so the global wipe rarely fires in
|
||||
# steady state and the simpler invalidation is enough.
|
||||
_dissolved_edges_cache: dict[
|
||||
tuple[int, float],
|
||||
tuple[list[Vector], list[tuple[int, int]]],
|
||||
] = {}
|
||||
_dissolved_edges_cache_token: int = -1
|
||||
|
||||
|
||||
def _get_cached_dissolved_edges(
|
||||
mesh: bpy.types.Mesh,
|
||||
angle_limit: float = radians(1.0),
|
||||
) -> tuple[list[Vector], list[tuple[int, int]]]:
|
||||
global _dissolved_edges_cache_token
|
||||
token = decorator_cache.get_decorator_cache_token()
|
||||
if token != _dissolved_edges_cache_token:
|
||||
_dissolved_edges_cache.clear()
|
||||
_dissolved_edges_cache_token = token
|
||||
key = (mesh.session_uid, angle_limit)
|
||||
cached = _dissolved_edges_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit)
|
||||
_dissolved_edges_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
# Per-object epoch: bumped only when this specific object's transform or geometry
|
||||
# updates land in the depsgraph delta. Invalidation work scales with the number
|
||||
# of changed objects, not total scene size — moving one object leaves every
|
||||
# other entry valid. Bumped by the depsgraph handler below; cleared on
|
||||
# undo/redo/load alongside the cache dicts.
|
||||
_object_epochs: dict[int, int] = {}
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _bump_object_epochs_for_decoration(*args) -> None:
|
||||
# depsgraph_update_post is called as (scene, depsgraph) in 4.x but the
|
||||
# *args signature follows decorator_cache's defensive idiom.
|
||||
depsgraph = args[1] if len(args) >= 2 else None
|
||||
if depsgraph is None or not hasattr(depsgraph, "updates"):
|
||||
return
|
||||
for u in depsgraph.updates:
|
||||
if not isinstance(u.id, bpy.types.Object):
|
||||
continue
|
||||
if not (u.is_updated_geometry or u.is_updated_transform):
|
||||
continue
|
||||
# u.id is the evaluated COW copy; the cache keys are written from the
|
||||
# original Object (read by the draw handler), and session_uid can
|
||||
# differ across the COW boundary. Resolve to the original before keying.
|
||||
original = getattr(u.id, "original", u.id)
|
||||
if original is None:
|
||||
continue
|
||||
uid = original.session_uid
|
||||
_object_epochs[uid] = _object_epochs.get(uid, 0) + 1
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _clear_decoration_caches_globally(*args) -> None:
|
||||
# Undo/redo/load: depsgraph deltas can't be trusted to describe the
|
||||
# transition, so wipe every per-object cache state.
|
||||
_object_epochs.clear()
|
||||
_world_draw_data_cache.clear()
|
||||
_batch_cache.clear()
|
||||
|
||||
|
||||
def _decoration_invalidation_hooks() -> tuple:
|
||||
return (
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
|
||||
def install_decoration_cache_handlers() -> None:
|
||||
if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration)
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
if _clear_decoration_caches_globally not in hook:
|
||||
hook.append(_clear_decoration_caches_globally)
|
||||
|
||||
|
||||
def uninstall_decoration_cache_handlers() -> None:
|
||||
try:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration)
|
||||
except ValueError:
|
||||
pass
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
try:
|
||||
hook.remove(_clear_decoration_caches_globally)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered),
|
||||
# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are
|
||||
# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a
|
||||
# stale entry for an object that didn't change since the last build still hits.
|
||||
_world_draw_data_cache: dict[
|
||||
int,
|
||||
tuple[
|
||||
int,
|
||||
tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
],
|
||||
],
|
||||
] = {}
|
||||
|
||||
|
||||
def _get_cached_world_draw_data(
|
||||
obj: bpy.types.Object,
|
||||
) -> tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
]:
|
||||
uid = obj.session_uid
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _world_draw_data_cache.get(uid)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
|
||||
mw = obj.matrix_world
|
||||
verts = [tuple(mw @ v.co) for v in obj.data.vertices]
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
|
||||
ios_edges_attribute = obj.data.attributes.get("ios_edges")
|
||||
if ios_edges_attribute:
|
||||
# Loader-curated edges: read the attribute aligned with bm.edges order.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
edges_indices = [
|
||||
tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value
|
||||
]
|
||||
bm.free()
|
||||
line_verts = verts
|
||||
else:
|
||||
dissolved, edges_indices = _get_cached_dissolved_edges(obj.data)
|
||||
line_verts = [tuple(mw @ v) for v in dissolved]
|
||||
|
||||
result = (line_verts, verts, edges_indices, tris)
|
||||
_world_draw_data_cache[uid] = (epoch, result)
|
||||
return result
|
||||
|
||||
|
||||
# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch);
|
||||
# lookup compares epoch to _object_epochs[uid] so other objects' batches stay
|
||||
# alive when one object's depsgraph delta bumps only its own epoch. The cached
|
||||
# batches reference GPU-side buffers tied to Blender's built-in shaders, which
|
||||
# are themselves cached by name (gpu.shader.from_builtin returns the same
|
||||
# handle each call), so they stay drawable across frames.
|
||||
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
|
||||
# density stays coherent across zoom. Dash + gap = period; dash_width controls
|
||||
# the "on" portion.
|
||||
_DASH_PERIOD_METERS: float = 0.20
|
||||
_DASH_WIDTH_METERS: float = 0.10
|
||||
# Solid front pass is rendered wider than the dashed back pass so its halo
|
||||
# overpowers the dashed center on visible edges even when the WIRE-display
|
||||
# overlay biases the depth buffer at outline pixels.
|
||||
_DASH_LINE_WIDTH: float = 1.5
|
||||
_SOLID_LINE_WIDTH: float = 2.5
|
||||
# Per-iteration default line width used by every non-occlusion draw call in
|
||||
# this decorator's ``__call__``. Restored after each occlusion pair so the
|
||||
# next draw isn't silently inheriting the wider solid-pass override.
|
||||
_DEFAULT_LINE_WIDTH: float = 2.0
|
||||
|
||||
|
||||
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
|
||||
uid = cache_key[0]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _batch_cache.get(cache_key)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
|
||||
def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None:
|
||||
uid = cache_key[0]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
_batch_cache[cache_key] = (epoch, batch)
|
||||
|
||||
|
||||
def is_filling_supported(element) -> bool:
|
||||
"""True when Bonsai's opening generator can derive an opening from this
|
||||
element. IFC's schema permits any IfcElement as a filling; Bonsai
|
||||
currently supports only IfcDoor and IfcWindow because those are the
|
||||
classes with OverallWidth/OverallHeight attributes (or their types'
|
||||
ELEVATION_VIEW profiles) that the generator can consume."""
|
||||
return element is not None and element.is_a() in ("IfcDoor", "IfcWindow")
|
||||
|
||||
|
||||
class FilledOpeningGenerator:
|
||||
def generate(
|
||||
@@ -50,9 +255,15 @@ class FilledOpeningGenerator:
|
||||
filling_obj: bpy.types.Object,
|
||||
voided_obj: bpy.types.Object,
|
||||
target: Optional[Vector] = None,
|
||||
preserve_placement: bool = False,
|
||||
) -> Union[None, str]:
|
||||
"""
|
||||
:param target: Target opening position. If ommited, cursor position is used.
|
||||
:param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is
|
||||
and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening
|
||||
is still created at the filling's current world position. Useful
|
||||
when the caller (e.g. the SHIFT-add-opening gizmo flow) has
|
||||
already positioned the filling intentionally.
|
||||
:return: None if there was no errors, otherwise returns a string with error message.
|
||||
"""
|
||||
props = tool.Model.get_model_props()
|
||||
@@ -74,7 +285,7 @@ class FilledOpeningGenerator:
|
||||
should_set_z_level = False
|
||||
|
||||
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
|
||||
if voided_obj.data:
|
||||
if not preserve_placement and voided_obj.data:
|
||||
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
|
||||
if not raycast[0]:
|
||||
target = filling_obj.matrix_world.translation.copy()
|
||||
@@ -207,18 +418,35 @@ class FilledOpeningGenerator:
|
||||
representation = tool.Geometry.get_representation_by_context(voided_element, context)
|
||||
assert representation
|
||||
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=voided_obj,
|
||||
representation=representation,
|
||||
)
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
|
||||
def preserve_opening_on_type_change(
|
||||
self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]
|
||||
) -> None:
|
||||
"""Pre-listener for type.assign_type: anchor the old type's void before reassigning.
|
||||
|
||||
A custom void that lives only on an occurrence (the type has no 'Reference'
|
||||
template) would be lost when that occurrence is moved to another type - the
|
||||
post-assign regeneration replaces it. Promoting it onto its current type first
|
||||
keeps it durable, so switching back later restores it. Idempotent and only acts on
|
||||
genuinely custom (non-extrusion) voids.
|
||||
"""
|
||||
relating_type = settings.get("relating_type")
|
||||
for related_object in settings.get("related_objects") or []:
|
||||
if not getattr(related_object, "FillsVoids", None):
|
||||
continue
|
||||
old_type = ifcopenshell.util.element.get_type(related_object)
|
||||
if old_type and old_type != relating_type:
|
||||
self.promote_opening_to_type(old_type)
|
||||
|
||||
def regenerate_from_type(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
|
||||
relating_type = settings["relating_type"]
|
||||
|
||||
for related_object in settings["related_objects"]:
|
||||
self._regenerate_from_type(related_object)
|
||||
# Filling type-switch on an array of fillings fans out N host recuts —
|
||||
# one per related object — without batching. Coalesce them.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
for related_object in settings["related_objects"]:
|
||||
self._regenerate_from_type(related_object)
|
||||
|
||||
def _regenerate_from_type(self, related_object: ifcopenshell.entity_instance) -> None:
|
||||
filling = related_object
|
||||
@@ -228,6 +456,13 @@ class FilledOpeningGenerator:
|
||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||
voided_element = opening.VoidsElements[0].RelatingBuildingElement
|
||||
|
||||
# Always regenerate the opening to reflect the *assigned* type's void: its
|
||||
# 'Reference' template if it has one (generate_opening_from_filling consults it),
|
||||
# else a sibling occurrence's opening, else a generated extrusion. We deliberately
|
||||
# do NOT preserve the previous type's custom void on a type change - a custom void
|
||||
# now survives duplicate_type/append by being anchored on the type as a template
|
||||
# (promote_opening_to_type / harvest), so keeping the old void here would just show
|
||||
# the wrong type's opening (e.g. switching to a plain type would keep the faceset).
|
||||
opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
|
||||
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
|
||||
@@ -267,12 +502,7 @@ class FilledOpeningGenerator:
|
||||
representation = tool.Geometry.get_active_representation(voided_obj)
|
||||
if not representation:
|
||||
continue
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=voided_obj,
|
||||
representation=representation,
|
||||
)
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
|
||||
def generate_opening_from_filling(
|
||||
self,
|
||||
@@ -289,6 +519,14 @@ class FilledOpeningGenerator:
|
||||
profile = None
|
||||
filling_type = ifcopenshell.util.element.get_type(filling)
|
||||
if filling_type:
|
||||
# A stored opening template (e.g. a custom IfcPolygonalFaceSet carried
|
||||
# across bim.duplicate_type / append) takes priority over generating a
|
||||
# default extrusion. Returning the shared template representation lets the
|
||||
# caller's map_representation reuse its IfcRepresentationMap, so this
|
||||
# opening stays in sync with the type template and its sibling occurrences.
|
||||
opening_template = self.get_type_opening_representation(filling_type)
|
||||
if opening_template is not None:
|
||||
return opening_template
|
||||
profile = ifcopenshell.util.representation.get_representation(
|
||||
filling_type, "Model", "Profile", "ELEVATION_VIEW"
|
||||
)
|
||||
@@ -386,6 +624,228 @@ class FilledOpeningGenerator:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_opening_representation_custom(self, opening: ifcopenshell.entity_instance) -> bool:
|
||||
"""Whether the opening's Body has user-authored geometry rather than a generated extrusion.
|
||||
|
||||
Openings produced by ``generate_opening_from_filling`` always consist of a
|
||||
single ``IfcExtrudedAreaSolid``. Anything else (a tessellation such as an
|
||||
``IfcPolygonalFaceSet``, a brep, a CSG solid, etc.) was authored by the user
|
||||
and must not be silently replaced with a default extrusion.
|
||||
"""
|
||||
representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
return False
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
return any(not item.is_a("IfcExtrudedAreaSolid") for item in representation.Items)
|
||||
|
||||
def should_preserve_opening(self, opening: ifcopenshell.entity_instance) -> bool:
|
||||
"""Whether an opening's geometry is worth anchoring on the type as a template.
|
||||
|
||||
True for user-authored geometry (a tessellation, brep, etc.) or a *manually adjusted*
|
||||
extrusion - one that no longer matches the default ``generate_opening_from_filling``
|
||||
would produce for its filling. A plain generated extrusion is regenerable, so it
|
||||
returns False and is left to regenerate.
|
||||
"""
|
||||
if self.is_opening_representation_custom(opening):
|
||||
return True
|
||||
return self._is_adjusted_extrusion(opening)
|
||||
|
||||
def _is_adjusted_extrusion(self, opening: ifcopenshell.entity_instance) -> bool:
|
||||
"""Whether the opening's extrusion diverges from the default for its filling.
|
||||
|
||||
Generates the default transiently, compares the axis-aligned bounding boxes of the
|
||||
two bodies (both in the opening's local frame), then removes the temporary default.
|
||||
A conservative False is returned when the default cannot be computed.
|
||||
"""
|
||||
filling = opening.HasFillings[0].RelatedBuildingElement if getattr(opening, "HasFillings", None) else None
|
||||
filling_obj = tool.Ifc.get_object(filling) if filling else None
|
||||
current = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if not filling_obj or current is None:
|
||||
return False
|
||||
current = ifcopenshell.util.representation.resolve_representation(current)
|
||||
default_representation = self.generate_opening_from_filling(filling, filling_obj)
|
||||
try:
|
||||
settings = ifcopenshell.geom.settings()
|
||||
current_bbox = self._representation_bbox(settings, current)
|
||||
default_bbox = self._representation_bbox(settings, default_representation)
|
||||
finally:
|
||||
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=default_representation)
|
||||
if current_bbox is None or default_bbox is None:
|
||||
return False
|
||||
(cur_min, cur_max), (def_min, def_max) = current_bbox, default_bbox
|
||||
tolerance = 1e-3 # 1 mm; differing extents/position => manually adjusted
|
||||
return bool(np.any(np.abs(cur_min - def_min) > tolerance) or np.any(np.abs(cur_max - def_max) > tolerance))
|
||||
|
||||
@staticmethod
|
||||
def _representation_bbox(settings: Any, representation: ifcopenshell.entity_instance):
|
||||
try:
|
||||
geometry = ifcopenshell.geom.create_shape(settings, representation)
|
||||
except Exception:
|
||||
return None
|
||||
verts = ifcopenshell.util.shape.get_vertices(geometry)
|
||||
if len(verts) == 0:
|
||||
return None
|
||||
return verts.min(axis=0), verts.max(axis=0)
|
||||
|
||||
def get_type_opening_representation(
|
||||
self, filling_type: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the type's stored opening template (its 'Reference' representation), if any.
|
||||
|
||||
The template is the shared opening body anchored on the type as a
|
||||
'Reference'-identified representation map (see
|
||||
:meth:`set_type_opening_representation`). Storing it on the type lets a
|
||||
custom opening survive ``bim.duplicate_type`` and project append, which copy
|
||||
the type's ``RepresentationMaps`` but not an opening shared only between
|
||||
occurrences.
|
||||
"""
|
||||
for representation_map in filling_type.RepresentationMaps or []:
|
||||
representation = representation_map.MappedRepresentation
|
||||
if representation.RepresentationIdentifier == "Reference":
|
||||
return representation
|
||||
|
||||
def set_type_opening_representation(
|
||||
self, filling_type: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
|
||||
) -> None:
|
||||
"""Anchor an opening body representation on the type as its 'Reference' template.
|
||||
|
||||
``representation`` is tagged 'Reference' (so it is excluded from the
|
||||
occurrence body geometry, see
|
||||
``ifcopenshell.api.type.map_type_representations``) and the
|
||||
``IfcRepresentationMap`` wrapping it is registered in the type's
|
||||
``RepresentationMaps``, replacing any previous 'Reference' map. The existing map
|
||||
is reused when present so that occurrences mapping over it stay in sync with the
|
||||
type template. Idempotent.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
representation.RepresentationIdentifier = "Reference"
|
||||
representation_map = next(
|
||||
(i for i in ifc_file.get_inverse(representation) if i.is_a("IfcRepresentationMap")), None
|
||||
)
|
||||
if representation_map is None:
|
||||
mapping_origin = ifc_file.createIfcAxis2Placement3D(
|
||||
ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
|
||||
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
)
|
||||
representation_map = ifc_file.createIfcRepresentationMap(mapping_origin, representation)
|
||||
# Keep all non-'Reference' maps (Body, Annotation, ...) plus this one, dropping any
|
||||
# previous 'Reference' template so the type carries exactly one.
|
||||
new_maps = [
|
||||
m
|
||||
for m in (filling_type.RepresentationMaps or [])
|
||||
if m == representation_map or m.MappedRepresentation.RepresentationIdentifier != "Reference"
|
||||
]
|
||||
if representation_map not in new_maps:
|
||||
new_maps.append(representation_map)
|
||||
filling_type.RepresentationMaps = new_maps
|
||||
|
||||
def update_type_template_from_opening(self, opening: ifcopenshell.entity_instance) -> None:
|
||||
"""Write an edited opening's geometry back to its filling type's 'Reference' template.
|
||||
|
||||
After a user edits an opening's void shape, anchor the new geometry on the type so
|
||||
the change is durable (survives duplicate_type/append and switching the type away
|
||||
and back) and propagates to sibling occurrences. Only acts on custom (non-extrusion)
|
||||
geometry; a re-generated extrusion needs no template.
|
||||
"""
|
||||
if not getattr(opening, "HasFillings", None) or not self.is_opening_representation_custom(opening):
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
new_representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if not new_representation:
|
||||
return
|
||||
new_representation = ifcopenshell.util.representation.resolve_representation(new_representation)
|
||||
voided_objs_to_reload: set[bpy.types.Object] = set()
|
||||
for rel in opening.HasFillings:
|
||||
filling_type = ifcopenshell.util.element.get_type(rel.RelatedBuildingElement)
|
||||
if not filling_type:
|
||||
continue
|
||||
old_template = self.get_type_opening_representation(filling_type)
|
||||
if old_template is not None and old_template != new_representation:
|
||||
# The edit gave this opening its own geometry; re-point the shared template
|
||||
# map - and therefore every sibling occurrence mapping over it - at the
|
||||
# edited geometry, then drop the now-orphaned old template.
|
||||
for inverse in ifc_file.get_inverse(old_template):
|
||||
if inverse.is_a("IfcRepresentationMap"):
|
||||
inverse.MappedRepresentation = new_representation
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_template)
|
||||
self.set_type_opening_representation(filling_type, new_representation)
|
||||
|
||||
# Re-map every other occurrence's opening onto the type template so the edit
|
||||
# propagates even to siblings that have their own independent opening geometry
|
||||
# (i.e. openings that never shared the template's IfcRepresentationMap).
|
||||
for occurrence in ifcopenshell.util.element.get_types(filling_type):
|
||||
sibling_opening = (
|
||||
occurrence.FillsVoids[0].RelatingOpeningElement
|
||||
if getattr(occurrence, "FillsVoids", None)
|
||||
else None
|
||||
)
|
||||
if not sibling_opening or sibling_opening == opening:
|
||||
continue
|
||||
if not self._remap_opening_to_template(sibling_opening, new_representation):
|
||||
continue
|
||||
if sibling_opening.VoidsElements:
|
||||
voided_element = sibling_opening.VoidsElements[0].RelatingBuildingElement
|
||||
for part in ifcopenshell.util.element.get_parts(voided_element) or [voided_element]:
|
||||
if voided_obj := tool.Ifc.get_object(part):
|
||||
voided_objs_to_reload.add(voided_obj)
|
||||
|
||||
# Reload affected host objects so the viewport re-booleans with the propagated void.
|
||||
for voided_obj in voided_objs_to_reload:
|
||||
representation = tool.Geometry.get_active_representation(voided_obj)
|
||||
if representation:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
|
||||
)
|
||||
|
||||
def _remap_opening_to_template(
|
||||
self, opening: ifcopenshell.entity_instance, template_representation: ifcopenshell.entity_instance
|
||||
) -> bool:
|
||||
"""Point an opening's Body at the shared type template, purging its old standalone body.
|
||||
|
||||
:return: True if the opening was changed, False if it already maps over the template.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
old_body = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if old_body is not None and (
|
||||
ifcopenshell.util.representation.resolve_representation(old_body) == template_representation
|
||||
):
|
||||
return False
|
||||
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
||||
ifc_file, representation=template_representation
|
||||
)
|
||||
# The mapped wrapper is the opening's own Body (the 'Reference' identifier belongs to
|
||||
# the type template it maps over, not to the occurrence's representation).
|
||||
mapped_representation.RepresentationIdentifier = "Body"
|
||||
if old_body is not None:
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_body)
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=opening, representation=mapped_representation)
|
||||
return True
|
||||
|
||||
def promote_opening_to_type(self, filling_type: ifcopenshell.entity_instance) -> None:
|
||||
"""Promote a custom opening from an occurrence to a 'Reference' template on the type.
|
||||
|
||||
Called before a type is copied (``bim.duplicate_type``) so that a custom
|
||||
(non-extrusion) opening, currently shared only between occurrences, is
|
||||
anchored on the type itself and therefore carried to the copy. No-op if the
|
||||
type already has a template or has no custom opening to promote.
|
||||
"""
|
||||
if self.get_type_opening_representation(filling_type):
|
||||
return
|
||||
for occurrence in ifcopenshell.util.element.get_types(filling_type):
|
||||
if not getattr(occurrence, "FillsVoids", None):
|
||||
continue
|
||||
opening = occurrence.FillsVoids[0].RelatingOpeningElement
|
||||
if not self.should_preserve_opening(opening):
|
||||
continue
|
||||
representation = ifcopenshell.util.representation.get_representation(
|
||||
opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
self.set_type_opening_representation(filling_type, representation)
|
||||
return
|
||||
|
||||
def get_existing_opening_occurrence_if_any(
|
||||
self, filling: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
@@ -407,6 +867,31 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return context.selected_objects
|
||||
|
||||
def _execute(self, context):
|
||||
# N selected fillings × M voided host parts would fire N×M host recuts
|
||||
# without batching. Coalesce per host.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
return self._recalculate_fills(context)
|
||||
|
||||
def _recalculate_fills(self, context):
|
||||
# Refresh each selected filling's mapped opening source before
|
||||
# recutting the host. Dedup by source id covers the common shared-
|
||||
# source case in one rewrite while leaving unrelated sibling sources
|
||||
# untouched.
|
||||
seen_source_ids: set[int] = set()
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.FillsVoids:
|
||||
continue
|
||||
opening = element.FillsVoids[0].RelatingOpeningElement
|
||||
body = tool.Geometry.get_body_representation(opening)
|
||||
if body is None:
|
||||
continue
|
||||
source = tool.Geometry.resolve_mapped_representation(body)
|
||||
if source.id() in seen_source_ids:
|
||||
continue
|
||||
seen_source_ids.add(source.id())
|
||||
tool.Model.regenerate_filling_opening_body(element)
|
||||
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.FillsVoids:
|
||||
@@ -435,12 +920,7 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if building_obj and building_obj.data:
|
||||
representation = tool.Geometry.get_active_representation(building_obj)
|
||||
if representation:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=building_obj,
|
||||
representation=representation,
|
||||
)
|
||||
tool.Geometry.recut_host(building_obj, representation)
|
||||
|
||||
# Refresh cut decorator
|
||||
DecoratorData.cut_cache.clear()
|
||||
@@ -558,6 +1038,29 @@ class AddBoolean(Operator, tool.Ifc.Operator):
|
||||
tool.Root.reload_item_decorator()
|
||||
|
||||
|
||||
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.toggle_host_openings"
|
||||
bl_label = "Toggle Openings"
|
||||
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
# Opening visibility is independent of host geometry — don't commit any
|
||||
# active parametric edit; the user can keep editing the host.
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ShowOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.show_openings"
|
||||
bl_label = "Show Openings"
|
||||
@@ -739,27 +1242,32 @@ class EditOpenings(Operator, tool.Ifc.Operator):
|
||||
for opening_element in opening_elements:
|
||||
opening_obj = tool.Ifc.get_object(opening_element)
|
||||
|
||||
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
||||
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
||||
tool.Ifc, similar_openings
|
||||
)
|
||||
building_objs.update(similar_openings_building_objs)
|
||||
|
||||
if opening_obj:
|
||||
if tool.Ifc.is_edited(opening_obj):
|
||||
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
||||
bonsai.core.geometry.edit_similar_opening_placement(
|
||||
tool.Geometry, opening_element, similar_openings
|
||||
)
|
||||
elif tool.Ifc.is_moved(opening_obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj)
|
||||
opening_edited = tool.Ifc.is_edited(opening_obj)
|
||||
opening_moved = tool.Ifc.is_moved(opening_obj)
|
||||
# Sibling walls only need a viewport-level refresh when the
|
||||
# opening's shape or placement actually changed — a pure
|
||||
# show/hide toggle leaves them in their existing state.
|
||||
if opening_edited or opening_moved:
|
||||
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
||||
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
||||
tool.Ifc, similar_openings
|
||||
)
|
||||
building_objs.update(similar_openings_building_objs)
|
||||
if opening_edited:
|
||||
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
||||
# Persist the edited void onto the filling type's 'Reference' template so
|
||||
# it survives type duplication/append/switching and propagates to siblings.
|
||||
self.update_type_template_from_opening(opening_element)
|
||||
else:
|
||||
bonsai.core.geometry.edit_object_placement(
|
||||
tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
|
||||
)
|
||||
bonsai.core.geometry.edit_similar_opening_placement(
|
||||
tool.Geometry, opening_element, similar_openings
|
||||
)
|
||||
building_objs.update(self.get_all_building_objects_of_similar_openings(opening_element))
|
||||
|
||||
building_objs.update(
|
||||
self.get_all_building_objects_of_similar_openings(opening_element)
|
||||
) # NB this has nothing to do with clone similar_opening
|
||||
tool.Ifc.unlink(element=opening_element)
|
||||
if props.representation_obj == opening_obj:
|
||||
props.representation_obj = None
|
||||
@@ -797,6 +1305,12 @@ class CloneOpening(Operator, tool.Ifc.Operator):
|
||||
return True
|
||||
|
||||
def _execute(self, context):
|
||||
# The voided host may be an aggregate whose parts each get recut.
|
||||
# Coalesce per host so a many-parts aggregate doesn't fan out.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
return self._clone_opening(context)
|
||||
|
||||
def _clone_opening(self, context):
|
||||
# NOTE: Operator displayed in UI only with IfcOpeningElement being active.
|
||||
ifc_file = tool.Ifc.get()
|
||||
objects = bpy.context.selected_objects
|
||||
@@ -826,12 +1340,7 @@ class CloneOpening(Operator, tool.Ifc.Operator):
|
||||
continue
|
||||
representation = tool.Geometry.get_active_representation(obj)
|
||||
assert representation
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=representation,
|
||||
)
|
||||
tool.Geometry.recut_host(obj, representation)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -941,7 +1450,6 @@ class SelectBoolean(Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# TODO: merge with ProfileDecorator?
|
||||
class DecorationsHandler:
|
||||
installed = None
|
||||
|
||||
@@ -951,6 +1459,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 +1468,79 @@ 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, cache_key=None):
|
||||
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
|
||||
#
|
||||
# The solid front pass is rendered WIDER than the dashed back pass so it
|
||||
# produces a halo around the line center, beyond the depth-bias zone that
|
||||
# Blender's overlay engine writes when an opening is set to WIRE display.
|
||||
# Without the width difference, the wire bias makes the center-pixel
|
||||
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
|
||||
# wire depth) so the solid pass would lose to the dashed back pass even
|
||||
# on visible edges. The halo gives the solid pass enough screen-space to
|
||||
# overpower the dashed pattern visually.
|
||||
#
|
||||
# Dashed renders first at the standard width so the solid overlay's wider
|
||||
# halo cleanly hides it on visible edges; on occluded edges the solid
|
||||
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
|
||||
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if front_batch is None:
|
||||
return
|
||||
|
||||
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
|
||||
dash_batch = None
|
||||
if dashed_cache_key is not None:
|
||||
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
|
||||
if dash_batch is None:
|
||||
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
|
||||
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
|
||||
)
|
||||
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
|
||||
if dash_batch is not None and dashed_cache_key is not None:
|
||||
_store_batch_in_cache(dashed_cache_key, dash_batch)
|
||||
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
front_color = list(color)
|
||||
front_color[3] = 1.0
|
||||
self.line_shader.uniform_float("color", front_color)
|
||||
|
||||
if dash_batch is not None:
|
||||
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("ALWAYS")
|
||||
dash_batch.draw(self.line_shader)
|
||||
|
||||
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
front_batch.draw(self.line_shader)
|
||||
|
||||
# Restore the per-iteration default set at the top of __call__ so
|
||||
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
|
||||
# not silently affected by the front-pass width override.
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
gpu.state.depth_test_set(original_depth_test)
|
||||
|
||||
def __call__(self, context):
|
||||
props = tool.Model.get_model_props()
|
||||
if not props.openings:
|
||||
@@ -1001,7 +1574,7 @@ class DecorationsHandler:
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
# POLYLINE_UNIFORM_COLOR specific uniforms
|
||||
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
|
||||
self.line_shader.uniform_float("lineWidth", 2.0)
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
|
||||
# general shader
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
@@ -1039,23 +1612,18 @@ class DecorationsHandler:
|
||||
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
|
||||
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
|
||||
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
|
||||
else:
|
||||
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 +1637,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,274 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Shared helpers for Bonsai's parametric preview flows.
|
||||
|
||||
Multiple Bonsai features follow the same Scene-level preview pattern:
|
||||
|
||||
Enable<X>Preview — validates a selection, populates draft state on
|
||||
``Scene.BIMPreviewProperties.<x>``, flips ``is_active``.
|
||||
Gizmo<X>Preview — polls on ``is_active``, surfaces tunable widgets +
|
||||
validate/cancel icons.
|
||||
<X>PreviewDecorator — GPU lines drawn while ``is_active`` is True.
|
||||
Finish<X>Preview — direct ``bpy.ops.bim.<verb>(...)`` call with kwargs
|
||||
read off the draft state, then clears it.
|
||||
Cancel<X>Preview — pure state reset.
|
||||
|
||||
The MEP bend and wall fillet flows are the two current callers. They write
|
||||
their Finish / Cancel operators directly, matching the convention used
|
||||
throughout the rest of ``bim/module/model/`` for operator-to-operator
|
||||
dispatch (explicit ``bpy.ops.bim.X(kwarg=value)`` at the call site, no
|
||||
string indirection). This module hosts the cross-cutting accessors only;
|
||||
no base class layer.
|
||||
|
||||
The GPU draw-handler lifecycle for ``<X>PreviewDecorator`` lives on the
|
||||
feature-neutral ``tool.Blender.ViewportDecorator`` base, which every
|
||||
viewport decorator (preview or otherwise) inherits from."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
# --- Props accessors ---------------------------------------------------------
|
||||
|
||||
|
||||
def get_preview_props(context: bpy.types.Context, attr: str):
|
||||
"""Resolve a child preview PropertyGroup under ``Scene.BIMPreviewProperties``.
|
||||
|
||||
Returns ``None`` if the umbrella isn't attached yet — true briefly
|
||||
during addon register and during plug-out, so polls / draw callbacks
|
||||
must defend against ``None`` rather than assuming the prop is always
|
||||
available. Also tolerates contexts without a ``scene`` attribute
|
||||
(test mocks built from ``SimpleNamespace``)."""
|
||||
scene = getattr(context, "scene", None)
|
||||
if scene is None:
|
||||
return None
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
|
||||
def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
|
||||
"""``True`` while a specific preview is open. Used by sibling gizmo
|
||||
polls to hide themselves so the preview is the only interactive
|
||||
surface in the viewport (the bend / fillet preview groups take over
|
||||
the same selection's icon stack)."""
|
||||
props = get_preview_props(context, attr)
|
||||
return bool(props is not None and props.is_active)
|
||||
|
||||
|
||||
def any_preview_active(context: bpy.types.Context) -> bool:
|
||||
"""``True`` if any registered preview is currently open. Sister gizmo
|
||||
polls call this to hide themselves uniformly during ANY preview, so a
|
||||
new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates
|
||||
every parametric gizmo without each one growing a specific check."""
|
||||
for attr, _op_name in PREVIEW_CANCEL_OPS:
|
||||
if is_preview_active(context, attr):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Lazy closure factories --------------------------------------------------
|
||||
#
|
||||
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
|
||||
# ``move_get_cb`` / ``move_set_cb`` callbacks. The closures re-resolve
|
||||
# ``bpy.context.scene`` per CALL rather than capturing it at setup() time
|
||||
# — the captured Scene's RNA struct can be freed on file open / undo, and
|
||||
# referencing a freed struct crashes Blender. Lazy lookup survives the
|
||||
# whole undo / reload lifecycle.
|
||||
|
||||
|
||||
def make_props_callback(attr: str) -> Callable[[], Any]:
|
||||
"""Return a zero-arg callable that lazily fetches the preview props.
|
||||
|
||||
Equivalent to ``getattr(bpy.context.scene.BIMPreviewProperties, attr)``
|
||||
with full defensiveness against missing scene / missing umbrella."""
|
||||
|
||||
def _props():
|
||||
scene = bpy.context.scene
|
||||
preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
return _props
|
||||
|
||||
|
||||
def make_dim_getter(props_callback: Callable[[], Any], field: str) -> Callable[[], float]:
|
||||
"""Factory for ``BIM_GT_gizmo_dimension.move_get_cb`` reading a single
|
||||
FloatProperty off the live preview state. Returns ``0.0`` defensively
|
||||
when the props are temporarily unavailable so the widget doesn't crash
|
||||
Blender during plug-out / reload."""
|
||||
|
||||
def _get() -> float:
|
||||
props = props_callback()
|
||||
return getattr(props, field) if props is not None else 0.0
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
def make_dim_setter(
|
||||
props_callback: Callable[[], Any],
|
||||
field: str,
|
||||
min_value: float = 0.001,
|
||||
) -> Callable[[float], None]:
|
||||
"""Factory for ``BIM_GT_gizmo_dimension.move_set_cb`` writing a single
|
||||
FloatProperty + tagging viewport areas for redraw so the GPU preview
|
||||
decorator tracks the value live during drag. Clamps at ``min_value``
|
||||
to match the FloatProperty's declared lower bound."""
|
||||
|
||||
def _set(value: float) -> None:
|
||||
props = props_callback()
|
||||
if props is None:
|
||||
return
|
||||
setattr(props, field, max(min_value, float(value)))
|
||||
tool.Blender.update_all_viewports()
|
||||
|
||||
return _set
|
||||
|
||||
|
||||
# --- Shared Enable lifecycle helpers -----------------------------------------
|
||||
|
||||
|
||||
def sync_uncommitted_moves(objects: list) -> None:
|
||||
"""Push any Blender-side translation / rotation of ``objects`` back to
|
||||
their IFC ``ObjectPlacement`` before a preview decorator starts reading
|
||||
``obj.matrix_world`` per frame.
|
||||
|
||||
Without this sync, a user who grabbed-moved an object but didn't commit
|
||||
the move sees the live preview at the dragged position while the final
|
||||
commit lands at the stale IFC position — a confusing "where did my
|
||||
preview go?" experience. Both bend and fillet enable paths call this
|
||||
on the relevant pair just before activating the preview."""
|
||||
for obj in objects:
|
||||
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
|
||||
def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
|
||||
"""Reset a preview PropertyGroup to its idle state on commit / cancel.
|
||||
|
||||
Sets ``is_active`` to False and zeros every ``IntProperty`` whose name
|
||||
ends in ``_id`` (the entity-reference convention every preview follows).
|
||||
Other fields are left at their last value — defaults are re-applied on
|
||||
the next enable, so leaving them alone avoids a redundant write."""
|
||||
props.is_active = False
|
||||
for name, rna in props.bl_rna.properties.items():
|
||||
if name.endswith("_id") and rna.type == "INT":
|
||||
setattr(props, name, 0)
|
||||
|
||||
|
||||
# --- Standard Finish flow ----------------------------------------------------
|
||||
|
||||
|
||||
def commit_preview(
|
||||
operator: bpy.types.Operator,
|
||||
context: bpy.types.Context,
|
||||
attr: str,
|
||||
target_op_name: str,
|
||||
kwarg_names: tuple[str, ...],
|
||||
) -> set[str]:
|
||||
"""Standard Finish-Preview dispatch: validate context + active preview,
|
||||
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
|
||||
and clear the preview on success.
|
||||
|
||||
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
|
||||
by ``bpy.ops`` to ``RuntimeError`` — catching it here surfaces the message
|
||||
to the user via ``operator.report`` rather than leaving Blender's operator
|
||||
state half-broken (which silently disables downstream gizmo polls).
|
||||
|
||||
Returns the dispatched operator's result set verbatim so callers can
|
||||
pass it straight back from their own ``execute``."""
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = get_preview_props(context, attr)
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
if tool.Ifc.get() is None:
|
||||
operator.report({"ERROR"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
kwargs = {name: getattr(props, name) for name in kwarg_names}
|
||||
try:
|
||||
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
|
||||
except RuntimeError as exc:
|
||||
operator.report({"ERROR"}, str(exc))
|
||||
return {"CANCELLED"}
|
||||
if "FINISHED" in result:
|
||||
clear_preview_state(props)
|
||||
return result
|
||||
|
||||
|
||||
# --- Esc dispatch ------------------------------------------------------------
|
||||
|
||||
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
|
||||
("bend", "cancel_bend_preview"),
|
||||
("wall_fillet", "cancel_wall_fillet_preview"),
|
||||
)
|
||||
"""Registry of ``(child PointerProperty on Scene.BIMPreviewProperties, bim
|
||||
operator name)`` consulted by the Esc handler. Adding a new preview means
|
||||
appending one tuple; the forward-compat test pins that every preview
|
||||
PropertyGroup with ``is_active`` has an entry here."""
|
||||
|
||||
|
||||
def try_cancel_active_preview(context: bpy.types.Context) -> bool:
|
||||
"""Cancel every registered preview that is currently active.
|
||||
|
||||
Returns ``True`` iff at least one preview was cancelled. Multiple
|
||||
previews can be simultaneously active (e.g. a stale bend preview opened
|
||||
just before the user starts a wall fillet) — one Esc must clear them
|
||||
all rather than forcing the user to tap Esc once per preview.
|
||||
|
||||
Tags 3D viewports for redraw on success — the Esc keymap entry runs
|
||||
outside a viewport mouse event so the gizmo poll wouldn't re-evaluate
|
||||
until the next interaction without an explicit redraw."""
|
||||
cancelled = False
|
||||
for attr, op_name in PREVIEW_CANCEL_OPS:
|
||||
if is_preview_active(context, attr):
|
||||
getattr(bpy.ops.bim, op_name)()
|
||||
cancelled = True
|
||||
if cancelled:
|
||||
tool.Blender.update_all_viewports(context)
|
||||
return cancelled
|
||||
|
||||
|
||||
def discard_pending_previews(scene: bpy.types.Scene) -> None:
|
||||
"""Clear every active preview under ``Scene.BIMPreviewProperties`` so
|
||||
saved preview state never resurfaces on file load.
|
||||
|
||||
Mirrors ``tool.Parametric.heal_stale_edit_flags`` for the object-level
|
||||
parametric-edit lifecycle — except previews are *discarded* rather than
|
||||
validated. A preview's only UI cue is its in-viewport widget; reloading
|
||||
a ``.blend`` saved mid-preview restores the flag but not the surrounding
|
||||
user attention, and a stuck ``is_active`` silently hides every sibling
|
||||
gizmo poll gated on it.
|
||||
|
||||
Iterates ``PREVIEW_CANCEL_OPS`` so any preview registered for Esc
|
||||
cancellation is automatically covered here too. Sets ``is_active``
|
||||
directly rather than dispatching the cancel operator: load_post may
|
||||
fire before ``bpy.context.screen`` is reattached, and the cancel
|
||||
operators bail on ``context.screen is None``."""
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
if preview is None:
|
||||
return
|
||||
for attr, _op_name in PREVIEW_CANCEL_OPS:
|
||||
child = getattr(preview, attr, None)
|
||||
if child is not None and getattr(child, "is_active", False):
|
||||
child.is_active = False
|
||||
@@ -1002,6 +1002,14 @@ class EnableEditingExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
obj = context.active_object
|
||||
|
||||
# Commit any in-progress parametric (gizmo) draft on this object
|
||||
# before switching to axis-edit. Otherwise the in-memory draft
|
||||
# state is overwritten when the axis mesh is imported below,
|
||||
# silently discarding the user's pending dimension edits.
|
||||
if feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.commit_object_draft(obj, feature.finish_op)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
|
||||
axis = ifcopenshell.util.representation.get_representation(element, "Model", "Axis", "GRAPH_VIEW")
|
||||
@@ -1157,7 +1165,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
|
||||
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
|
||||
if connect_IfcFlowSegments:
|
||||
bpy.ops.bim.mep_connect_elements(
|
||||
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
|
||||
obj1_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
|
||||
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
|
||||
)
|
||||
|
||||
def modal(self, context, event):
|
||||
|
||||
@@ -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
|
||||
@@ -31,8 +33,10 @@ from bonsai.bim.module.drawing.decoration import CutDecorator
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
BoundingBoxDecorator,
|
||||
MEPSystemPathDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallSystemPathDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.door import update_door_modifier_bmesh
|
||||
from bonsai.bim.module.model.window import update_window_modifier_bmesh
|
||||
@@ -101,8 +105,12 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
|
||||
|
||||
|
||||
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
|
||||
bpy.ops.bim.enable_editing_array(item=self.is_editing)
|
||||
return
|
||||
# Skip the cleanup-time clear: Finish/Cancel sets relating_array_object back to None,
|
||||
# which has no source to hydrate from. Only the user-driven pick (None → some array)
|
||||
# should auto-enter edit on the picked source's layer 0.
|
||||
if self.relating_array_object is None:
|
||||
return
|
||||
bpy.ops.bim.enable_editing_array(item=0)
|
||||
|
||||
|
||||
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
|
||||
@@ -126,6 +134,19 @@ def update_slab_direction_decorator(self: "BIMModelProperties", context: bpy.typ
|
||||
SlabDirectionDecorator.uninstall()
|
||||
|
||||
|
||||
def update_paths_decorator(self: "BIMModelProperties", context: bpy.types.Context) -> None:
|
||||
"""Unified toggle for connected-element path overlays. Drives both the
|
||||
MEP and wall path decorators — each decorator's ``draw`` short-circuits
|
||||
when its kind of element isn't selected, so leaving both installed is
|
||||
cheap and lets one toggle cover any connected-element family."""
|
||||
if self.show_paths:
|
||||
MEPSystemPathDecorator.install(bpy.context)
|
||||
WallSystemPathDecorator.install(bpy.context)
|
||||
else:
|
||||
MEPSystemPathDecorator.uninstall()
|
||||
WallSystemPathDecorator.uninstall()
|
||||
|
||||
|
||||
def update_measure_xyz(self: "BIMModelProperties", context: bpy.types.Context) -> None:
|
||||
if self.show_bounding_box:
|
||||
BoundingBoxDecorator.install(context)
|
||||
@@ -193,14 +214,36 @@ 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:
|
||||
# Only FRAMELESS_PANEL can update live via bmesh.
|
||||
# WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation,
|
||||
# so it only updates on "Finish Editing" to avoid modifying IFC during preview.
|
||||
if self.railing_type == "FRAMELESS_PANEL":
|
||||
_get_updater("railing", "update_railing_modifier_bmesh")(context)
|
||||
_get_updater("railing", "update_railing_modifier_bmesh")(context)
|
||||
|
||||
|
||||
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
||||
@@ -210,6 +253,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
||||
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
|
||||
|
||||
|
||||
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
class BIMModelProperties(PropertyGroup):
|
||||
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
|
||||
relating_type_id: bpy.props.EnumProperty(
|
||||
@@ -312,6 +369,19 @@ class BIMModelProperties(PropertyGroup):
|
||||
default=False,
|
||||
update=update_slab_direction_decorator,
|
||||
)
|
||||
show_paths: bpy.props.BoolProperty(
|
||||
name="Show Paths",
|
||||
default=False,
|
||||
update=update_paths_decorator,
|
||||
description=(
|
||||
"Trace the connected element path from the selected element. For "
|
||||
"walls, follows IfcRelConnectsPathElements and draws each "
|
||||
"connected wall's reference axis with endpoint dots. For MEP "
|
||||
"elements, follows IfcRelConnectsPorts and draws each segment's "
|
||||
"axis plus a port-to-port spider for each fitting. Toggle off to "
|
||||
"skip the BFS traversal entirely."
|
||||
),
|
||||
)
|
||||
|
||||
prev_transform_orientation_slot_type: bpy.props.StringProperty(name="Previous Gizmo Orientation Type")
|
||||
prev_show_gizmo_object_translate: bpy.props.BoolProperty(name="Previous Gizmo Translate")
|
||||
@@ -359,6 +429,7 @@ class BIMModelProperties(PropertyGroup):
|
||||
offset: float
|
||||
show_wall_axis: bool
|
||||
show_slab_direction: bool
|
||||
show_paths: bool
|
||||
|
||||
prev_transform_orientation_slot_type: str
|
||||
prev_show_gizmo_object_translate: bool
|
||||
@@ -369,8 +440,13 @@ class BIMModelProperties(PropertyGroup):
|
||||
|
||||
|
||||
class BIMArrayProperties(PropertyGroup):
|
||||
is_editing: bpy.props.IntProperty(
|
||||
default=-1, description="Currently edited array index. -1 if not in array editing mode."
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while an array layer is in parametric edit mode. The specific layer is in editing_item_index.",
|
||||
)
|
||||
editing_item_index: bpy.props.IntProperty(
|
||||
default=-1,
|
||||
description="Index of the array layer currently being edited; -1 when not in edit mode.",
|
||||
)
|
||||
count: bpy.props.IntProperty(name="Count", default=0, min=0)
|
||||
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
|
||||
@@ -386,6 +462,15 @@ class BIMArrayProperties(PropertyGroup):
|
||||
name="Method",
|
||||
default="OFFSET",
|
||||
)
|
||||
per_child_opening: bpy.props.BoolProperty(
|
||||
name="Per-Child Opening",
|
||||
description=(
|
||||
"When the array parent fills a wall (or any voidable host), give each array child its own opening + "
|
||||
"filling pair so the host is cut once per child. Disable to leave the host uncut by the children — "
|
||||
"only the parent's original opening remains"
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
relating_array_object: bpy.props.PointerProperty(
|
||||
type=bpy.types.Object,
|
||||
name="Copy Array Properties",
|
||||
@@ -394,13 +479,15 @@ class BIMArrayProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: int
|
||||
is_editing: bool
|
||||
editing_item_index: int
|
||||
count: int
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
use_local_space: bool
|
||||
method: Literal["OFFSET", "DISTRIBUTE"]
|
||||
per_child_opening: bool
|
||||
sync_children: bool
|
||||
relating_array_object: Union[bpy.types.Object, None]
|
||||
|
||||
@@ -1631,6 +1718,133 @@ class BIMRoofProperties(PropertyGroup):
|
||||
setattr(target_props, prop_name, prop_value)
|
||||
|
||||
|
||||
class BIMSlabProperties(PropertyGroup):
|
||||
"""Transient state for the slab disconnect-access gizmo.
|
||||
|
||||
``is_editing`` flips True when the user clicks the pen icon on a slab
|
||||
that has wall connections — gating the per-wall disconnect icons in
|
||||
``GizmoSlabUnjoinWalls`` so they're hidden until the user opts in. No
|
||||
IFC draft state lives here: the disconnect operator commits directly,
|
||||
so this PropertyGroup carries only the UI gate."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(name="Slab Edit Active", default=False, options={"SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
|
||||
|
||||
class BIMWallProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric wall gizmo editing.
|
||||
|
||||
Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
|
||||
(preview only — no IFC writes), and either committed by `bim.finish_editing_wall`
|
||||
or discarded by `bim.cancel_editing_wall`.
|
||||
|
||||
The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
|
||||
current vs snap to skip unchanged params and guarantee a no-op session leaves the
|
||||
IFC file byte-identical.
|
||||
"""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while wall parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview box; cleared once the real "
|
||||
"IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_wall,
|
||||
description="Wall length along its reference axis (preview value; committed on finish).",
|
||||
)
|
||||
height: bpy.props.FloatProperty(
|
||||
name="Height",
|
||||
default=3.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_wall,
|
||||
description="Wall vertical height (preview value; committed on finish).",
|
||||
)
|
||||
x_angle: bpy.props.FloatProperty(
|
||||
name="Slope (X Angle)",
|
||||
default=0.0,
|
||||
soft_min=-math.pi / 3,
|
||||
soft_max=math.pi / 3,
|
||||
subtype="ANGLE",
|
||||
update=update_wall,
|
||||
description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
|
||||
)
|
||||
thickness: bpy.props.FloatProperty(
|
||||
name="Thickness",
|
||||
default=0.2,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
|
||||
)
|
||||
offset: bpy.props.FloatProperty(
|
||||
name="Offset",
|
||||
default=0.0,
|
||||
subtype="DISTANCE",
|
||||
description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
|
||||
)
|
||||
desired_offset_baseline: bpy.props.EnumProperty(
|
||||
items=[
|
||||
("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
|
||||
("CENTER", "Center", "Reference axis at the wall centreline"),
|
||||
("INTERIOR", "Interior", "Reference axis at the interior face"),
|
||||
],
|
||||
name="Desired Offset Baseline",
|
||||
default="CENTER",
|
||||
update=update_wall_offset_baseline,
|
||||
description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
|
||||
)
|
||||
anchor_x: bpy.props.FloatProperty(
|
||||
default=0.0,
|
||||
subtype="DISTANCE",
|
||||
description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
|
||||
)
|
||||
|
||||
snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
|
||||
snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
|
||||
snap_thickness: bpy.props.FloatProperty(
|
||||
description="Snapshot of thickness at edit-enable; commit skips no-op writes."
|
||||
)
|
||||
snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
|
||||
snap_x_angle: bpy.props.FloatProperty(
|
||||
subtype="ANGLE",
|
||||
description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_offset_baseline: bpy.props.StringProperty(
|
||||
default="",
|
||||
description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
height: float
|
||||
x_angle: float
|
||||
thickness: float
|
||||
offset: float
|
||||
desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
|
||||
anchor_x: float
|
||||
snap_length: float
|
||||
snap_height: float
|
||||
snap_thickness: float
|
||||
snap_offset: float
|
||||
snap_x_angle: float
|
||||
snap_offset_baseline: str
|
||||
|
||||
|
||||
class SnapMousePoint(PropertyGroup):
|
||||
x: bpy.props.FloatProperty(name="X")
|
||||
y: bpy.props.FloatProperty(name="Y")
|
||||
@@ -1762,3 +1976,236 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
|
||||
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
|
||||
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
|
||||
|
||||
|
||||
class BIMPipeSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric pipe-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while pipe-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_pipe_segment,
|
||||
description="Pipe-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMDuctSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric duct-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while duct-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_duct_segment,
|
||||
description="Duct-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMBendPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the bend-creation preview flow.
|
||||
|
||||
Scene-level (not per-object) because the bend involves two segments by
|
||||
IFC id — neither alone owns the draft."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the bend-creation preview flow is active.",
|
||||
)
|
||||
start_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the start (active) segment.",
|
||||
)
|
||||
end_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the end (other selected) segment.",
|
||||
)
|
||||
start_length: bpy.props.FloatProperty(
|
||||
name="Start Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the start (active) segment side",
|
||||
)
|
||||
end_length: bpy.props.FloatProperty(
|
||||
name="End Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the end (other) segment side",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.2,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Inner radius of the bend curve",
|
||||
)
|
||||
editing_bend_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing bend fitting being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this bend + its port connections before "
|
||||
"recreating with the new parameters."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
start_segment_id: int
|
||||
end_segment_id: int
|
||||
start_length: float
|
||||
end_length: float
|
||||
radius: float
|
||||
editing_bend_id: int
|
||||
|
||||
|
||||
class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the wall-fillet preview flow.
|
||||
|
||||
Scene-level because the fillet spans two walls and commits a third
|
||||
(corner) wall between them. ``SKIP_SAVE`` fields throughout."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the wall-fillet preview flow is active.",
|
||||
)
|
||||
wall_a_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of the active wall — the corner wall inherits its "
|
||||
"material layer set, height, x_angle, and type."
|
||||
),
|
||||
)
|
||||
wall_b_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the other selected wall.",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.5,
|
||||
soft_min=-10.0,
|
||||
soft_max=10.0,
|
||||
subtype="DISTANCE",
|
||||
unit="LENGTH",
|
||||
options={"SKIP_SAVE"},
|
||||
description="Radius of the circular arc connecting the two walls.",
|
||||
)
|
||||
editing_corner_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing fillet corner being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this corner + its connections before recreating "
|
||||
"with the new radius."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
wall_a_id: int
|
||||
wall_b_id: int
|
||||
radius: float
|
||||
editing_corner_id: int
|
||||
|
||||
|
||||
class BIMPreviewProperties(PropertyGroup):
|
||||
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
|
||||
|
||||
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
|
||||
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
bend: BIMBendPreviewProperties
|
||||
wall_fillet: BIMWallFilletPreviewProperties
|
||||
|
||||
|
||||
class BIMParametricEditDialogPrefs(PropertyGroup):
|
||||
"""Session-scoped flag for the parametric-edit pen-icon dispatcher.
|
||||
|
||||
Attached to ``WindowManager`` so the state lives for one Blender session
|
||||
and resets on restart — the right scope for "don't show this again for
|
||||
this session" toggles."""
|
||||
|
||||
suppress_shared_rep_warning: bpy.props.BoolProperty(
|
||||
name="Suppress shared-representation warning",
|
||||
description=(
|
||||
"When true, the pen-icon dispatcher skips the shared-geometry "
|
||||
"confirmation dialog. Resets on Blender restart."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
suppress_shared_rep_warning: bool
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import bmesh
|
||||
@@ -27,13 +28,24 @@ import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Vector
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model import prop
|
||||
from bonsai.bim.module.model.data import RailingData, refresh
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
from bonsai.bim.parametric_lifecycle import (
|
||||
CycleTypeMixin,
|
||||
PathPreservingEditMixin,
|
||||
PickTypeMixin,
|
||||
)
|
||||
from bonsai.tool.cad import WELD_TOLERANCE
|
||||
|
||||
V_ = tool.Blender.V_
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
|
||||
@@ -92,7 +104,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,
|
||||
@@ -125,6 +136,56 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": railing_data})
|
||||
|
||||
|
||||
def generate_wall_mounted_handrail_preview(
|
||||
obj: bpy.types.Object,
|
||||
props: "BIMRailingProperties",
|
||||
path_data: dict[str, Any],
|
||||
si_conversion: float,
|
||||
) -> None:
|
||||
"""Viewport-only WALL_MOUNTED_HANDRAIL preview: rebuild ``obj.data`` from the same
|
||||
geometry helper the IFC representation builder uses, without writing any IFC."""
|
||||
railing_path = [Vector(v) * si_conversion for v in path_data["verts"]]
|
||||
looped_path = path_data["edges"][-1][-1] == path_data["edges"][0][0]
|
||||
|
||||
geom = ifcopenshell.api.geometry.compute_wall_mounted_handrail_geometry(
|
||||
railing_path=railing_path,
|
||||
support_spacing=props.support_spacing,
|
||||
railing_diameter=props.railing_diameter,
|
||||
clear_width=props.clear_width,
|
||||
height=props.height,
|
||||
use_manual_supports=props.use_manual_supports,
|
||||
terminal_type=props.terminal_type,
|
||||
looped_path=looped_path,
|
||||
unit_scale=1.0, # props are already SI; bypass the IFC project-units conversion
|
||||
)
|
||||
|
||||
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
|
||||
|
||||
tool.Cad.sweep_disk_along_polyline(
|
||||
bm,
|
||||
[Vector(p) for p in geom.handrail_polyline],
|
||||
geom.handrail_radius,
|
||||
arc_indices=geom.handrail_arc_point_indices,
|
||||
)
|
||||
|
||||
for support in geom.supports:
|
||||
tool.Cad.sweep_disk_along_polyline(
|
||||
bm,
|
||||
[Vector(p) for p in support.arc_polyline],
|
||||
support.arc_radius,
|
||||
)
|
||||
tool.Cad.add_disk_extrusion(
|
||||
bm,
|
||||
Vector(support.disk_position),
|
||||
support.disk_radius,
|
||||
support.disk_depth,
|
||||
support.disk_z_rotation,
|
||||
)
|
||||
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||
tool.Blender.apply_bmesh(obj.data, bm)
|
||||
|
||||
|
||||
def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
||||
"""before using should make sure that Data contains up-to-date information.
|
||||
If BBIM Pset just changed should call refresh() before updating bmesh
|
||||
@@ -140,6 +201,13 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
||||
path_data = RailingData.data["path_data"]
|
||||
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
# WALL_MOUNTED_HANDRAIL renders the preview from the compute helper; IFC stays
|
||||
# untouched until Finish Editing rebuilds the representation.
|
||||
if not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL":
|
||||
generate_wall_mounted_handrail_preview(obj, props, path_data, si_conversion)
|
||||
return
|
||||
|
||||
# need to make sure we support edit mode
|
||||
# since users will probably be in edit mode when they'll be changing railing path
|
||||
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
|
||||
@@ -165,8 +233,6 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
||||
thickness = props.thickness
|
||||
spacing = props.spacing
|
||||
|
||||
# spacing
|
||||
# split each edge in 3 segments by 0.5 * spacing by x-y plane
|
||||
main_edges = bm.edges[:]
|
||||
for main_edge in main_edges:
|
||||
bm_split_edge_at_offset(main_edge, spacing)
|
||||
@@ -211,7 +277,7 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
||||
bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve)
|
||||
bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve)
|
||||
# to remove unnecessary verts in 0 spacing case
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
|
||||
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||
|
||||
@@ -271,8 +337,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]:
|
||||
segments.append((i - 1, 0))
|
||||
break
|
||||
|
||||
# skip path verts if they just go vertical to avoid errors
|
||||
if (v.co.xy - prev_v.co.xy).length <= 0.0001:
|
||||
# Vertical-only segments project to a degenerate XY edge; skip to avoid divide-by-zero downstream.
|
||||
if (v.co.xy - prev_v.co.xy).length <= WELD_TOLERANCE:
|
||||
continue
|
||||
|
||||
points.append(v.co)
|
||||
@@ -406,68 +472,628 @@ 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):
|
||||
"""Single-object (active_object) railing-edit hooks; path_data is preserved
|
||||
through the edit (path editing is a separate operator family)."""
|
||||
|
||||
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.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_railing_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _post_load_data(cls, data: dict) -> dict:
|
||||
# BIMRailingProperties.path_data is a StringProperty holding JSON.
|
||||
data["path_data"] = json.dumps(data["path_data"])
|
||||
return data
|
||||
|
||||
# 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"]
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""WALL_MOUNTED_HANDRAIL reloads the committed Body; others rebuild the preview bmesh."""
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
if props.railing_type == "WALL_MOUNTED_HANDRAIL":
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if body:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=body,
|
||||
)
|
||||
return
|
||||
update_railing_modifier_bmesh(context)
|
||||
|
||||
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)
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_railing"
|
||||
bl_label = "Finish Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
|
||||
"""Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses."""
|
||||
|
||||
bl_idname = "bim.cycle_railing_type"
|
||||
bl_label = "Cycle Railing Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_railing
|
||||
props_getter = tool.Model.get_railing_props
|
||||
type_literal = tool.Model.RailingType
|
||||
type_attr = "railing_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
class ToggleRailingUseManualSupports(bpy.types.Operator):
|
||||
"""Flip use_manual_supports on the active WALL_MOUNTED_HANDRAIL railing.
|
||||
|
||||
No-op unless a parametric edit is active and the railing is wall-mounted.
|
||||
"""
|
||||
|
||||
bl_idname = "bim.toggle_railing_use_manual_supports"
|
||||
bl_label = "Toggle Railing Manual Supports"
|
||||
bl_description = "Switch between automatic support spacing and manual per-vertex placement"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
resolved = tool.Model.resolve_active_props_for_edit(
|
||||
context,
|
||||
tool.Model.get_railing_props,
|
||||
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
|
||||
)
|
||||
if resolved is None:
|
||||
return {"CANCELLED"}
|
||||
_obj, props = resolved
|
||||
props.use_manual_supports = not props.use_manual_supports
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
|
||||
|
||||
bl_idname = "bim.pick_railing_terminal_type"
|
||||
bl_label = "Pick Railing Terminal Type"
|
||||
bl_description = "Pick the cap geometry applied at the rail ends"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
skip_element_check = True
|
||||
props_getter = tool.Model.get_railing_props
|
||||
type_literal = prop.CapType
|
||||
type_attr = "terminal_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
if (
|
||||
tool.Model.resolve_active_props_for_edit(
|
||||
context,
|
||||
tool.Model.get_railing_props,
|
||||
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
|
||||
)
|
||||
is None
|
||||
):
|
||||
return {"CANCELLED"}
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
def _format_attr_distance(attr_name: str):
|
||||
"""text_formatter that renders the named property as a distance, ignoring the
|
||||
dimension's visible-length argument (which is fixed for schematic gizmos)."""
|
||||
return lambda p, _v: tool.Unit.format_distance(getattr(p, attr_name))
|
||||
|
||||
|
||||
class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup):
|
||||
"""Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_railing_edition"
|
||||
bl_label = "Railing Editing Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
enable_editing_operator = "bim.enable_editing_railing"
|
||||
finish_editing_operator = "bim.finish_editing_railing"
|
||||
cancel_editing_operator = "bim.cancel_editing_railing"
|
||||
cycle_type_operator = "bim.cycle_railing_type"
|
||||
|
||||
props_getter = tool.Model.get_railing_props
|
||||
gizmo_pref_name = "railing"
|
||||
|
||||
# Schematic-local layout. +X → screen RIGHT, +Y → screen UP, +Z → toward viewer
|
||||
# (post billboard rotation). Each dimension is anchored alongside the feature it
|
||||
# measures so the label, not the bar length, carries the value.
|
||||
SCHEMATIC_MESH_HEIGHT_FRAC = 0.9 # Mesh top edge in schematic-local +Y
|
||||
SCHEMATIC_MESH_WIDTH_FRAC = 0.7 # Mesh side edges in schematic-local ±X
|
||||
SCHEMATIC_MESH_RAIL_Y_FRAC = SCHEMATIC_MESH_HEIGHT_FRAC / 2 # WALL_MOUNTED_HANDRAIL rail centreline
|
||||
SCHEMATIC_MESH_DEPTH_FRAC = 0.06 # Panel depth — small so the schematic reads as slabs not boxes
|
||||
# WALL_MOUNTED_HANDRAIL dimensions — fractions of schematic_box_size so they
|
||||
# scale with the host group's box size.
|
||||
SCHEMATIC_RAIL_RADIUS_FRAC = 0.05
|
||||
SCHEMATIC_RAIL_CLEAR_FRAC = 0.5 # Stylised — wider than real-world for visible bracket arm
|
||||
SCHEMATIC_RAIL_INSET_FRAC = 0.08 # Wall extends past the outermost support on both sides
|
||||
|
||||
@classmethod
|
||||
def schematic_rail_radius(cls) -> float:
|
||||
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_RADIUS_FRAC
|
||||
|
||||
@classmethod
|
||||
def schematic_rail_clear(cls) -> float:
|
||||
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_CLEAR_FRAC
|
||||
|
||||
# Axonometric 3/4 view: +Z projects down-and-left so the depth axis
|
||||
# is visibly separated from the back face. Without the X tilt, panel
|
||||
# thickness (schematic-local Z) collapses to a near-horizontal bar.
|
||||
schematic_view_rotation = Matrix.Rotation(math.radians(20), 4, "X") @ Matrix.Rotation(math.radians(-25), 4, "Y")
|
||||
|
||||
# Hover a dimension → highlight the schematic edges tagged with the matching feature.
|
||||
# Tags are written by the mesh builders. "spacing" is empty space (no edges) so it's
|
||||
# absent from this map and gracefully no-ops on hover.
|
||||
schematic_attr_to_feature = {
|
||||
"height": "panel_height",
|
||||
"thickness": "panel_thickness",
|
||||
"railing_diameter": "rail_tube",
|
||||
"clear_width": "bracket",
|
||||
"support_spacing": "bracket",
|
||||
}
|
||||
|
||||
schematic_dimension_props = [
|
||||
# ── FRAMELESS_PANEL ─────────────────────────────────────────────
|
||||
DimensionGizmoConfig(
|
||||
attr_name="height",
|
||||
axis=(0, 1, 0),
|
||||
min_value=0.01,
|
||||
# Gated to FRAMELESS_PANEL: in WALL_MOUNTED_HANDRAIL, height only
|
||||
# feeds TO_FLOOR / TO_END_POST_AND_FLOOR terminals so dragging it
|
||||
# is a no-op under the default "180" terminal.
|
||||
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
|
||||
matrix_position=lambda p: Vector((-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.08, 0.0, 0.0)),
|
||||
schematic_visible_length=SCHEMATIC_MESH_HEIGHT_FRAC,
|
||||
text_formatter=_format_attr_distance("height"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="thickness",
|
||||
axis=(0, 0, 1), # panel depth — projects to a true depth direction under the 3/4 tilt
|
||||
min_value=0.005,
|
||||
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
|
||||
matrix_position=lambda p: Vector(
|
||||
(
|
||||
(
|
||||
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||
- GizmoRailingSchematic.SCHEMATIC_MESH_GAP_HALF_WIDTH
|
||||
)
|
||||
/ 2,
|
||||
GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC + 0.05,
|
||||
-GizmoRailingSchematic.SCHEMATIC_MESH_DEPTH_FRAC / 2,
|
||||
)
|
||||
),
|
||||
schematic_visible_length=0.4, # longer than default to survive depth foreshortening
|
||||
text_formatter=_format_attr_distance("thickness"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="spacing",
|
||||
axis=(1, 0, 0),
|
||||
min_value=0.0, # zero-spacing collapses the picket gap into a single continuous panel
|
||||
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
|
||||
matrix_position=lambda p: Vector((0.0, -0.1, 0.0)),
|
||||
text_formatter=_format_attr_distance("spacing"),
|
||||
),
|
||||
# ── WALL_MOUNTED_HANDRAIL ──────────────────────────────────────
|
||||
DimensionGizmoConfig(
|
||||
attr_name="railing_diameter",
|
||||
axis=(0, 1, 0),
|
||||
min_value=0.001,
|
||||
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
|
||||
matrix_position=lambda p: Vector(
|
||||
(
|
||||
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.05,
|
||||
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC - 0.09,
|
||||
GizmoRailingSchematic.schematic_rail_clear(),
|
||||
)
|
||||
),
|
||||
text_formatter=_format_attr_distance("railing_diameter"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="clear_width",
|
||||
axis=(0, 0, 1), # +Z is the wall-to-rail perpendicular axis under the 3/4 tilt
|
||||
min_value=0.001,
|
||||
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
|
||||
matrix_position=lambda p: Vector(
|
||||
(
|
||||
0.0,
|
||||
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC,
|
||||
0.0,
|
||||
)
|
||||
),
|
||||
schematic_visible_length=0.36, # 2× default so the call-out survives depth projection
|
||||
text_formatter=_format_attr_distance("clear_width"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="support_spacing",
|
||||
axis=(1, 0, 0),
|
||||
min_value=0.05,
|
||||
visibility_condition=lambda p: (p.railing_type == "WALL_MOUNTED_HANDRAIL" and not p.use_manual_supports),
|
||||
matrix_position=lambda p: Vector(
|
||||
(
|
||||
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||
+ GizmoRailingSchematic.SCHEMATIC_RAIL_INSET_FRAC,
|
||||
-0.18,
|
||||
0.0,
|
||||
)
|
||||
),
|
||||
# Bare names (not Gizmo…SCHEMATIC_…) because the class is still under construction here.
|
||||
schematic_visible_length=SCHEMATIC_MESH_WIDTH_FRAC - 2 * SCHEMATIC_RAIL_INSET_FRAC,
|
||||
text_formatter=_format_attr_distance("support_spacing"),
|
||||
),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def schematic_cache_key(cls, props) -> tuple:
|
||||
"""Cache the schematic mesh by ``railing_type`` — proportions are fixed
|
||||
per type, so the bmesh build runs at most twice across a session
|
||||
(once for ``FRAMELESS_PANEL``, once for ``WALL_MOUNTED_HANDRAIL``)
|
||||
rather than once per draw call."""
|
||||
return (props.railing_type,)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create the WALL_MOUNTED_HANDRAIL-only affordances on the schematic.
|
||||
|
||||
Two static lock glyphs (open/closed) for toggling
|
||||
``use_manual_supports``: instantiate both and let the per-frame state
|
||||
query pick which one to show. State-aware icons use a static pair
|
||||
rather than a single dynamic gizmo to avoid ``prop_path`` resolution
|
||||
in the render path.
|
||||
|
||||
Plus a cycle-glyph at the rail end that opens the ``terminal_type``
|
||||
popup when clicked.
|
||||
"""
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
|
||||
self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
|
||||
"bim.toggle_railing_use_manual_supports",
|
||||
open_color=default_color,
|
||||
)
|
||||
|
||||
self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu")
|
||||
self.terminal_gizmo.color = default_color
|
||||
self.terminal_gizmo.color_highlight = highlight_color
|
||||
self.terminal_gizmo.use_draw_scale = False
|
||||
self.terminal_gizmo.alpha = 0.8
|
||||
self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type")
|
||||
|
||||
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
|
||||
"""Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos.
|
||||
|
||||
- Lock glyphs: only WALL_MOUNTED_HANDRAIL while editing. Show
|
||||
``lock_open`` when ``use_manual_supports`` is True, the closed
|
||||
padlock when False ("auto-spacing is locked to support_spacing").
|
||||
- Terminal gizmo: same gating, positioned just past the right rail
|
||||
end so it reads as "configure the rail's end cap".
|
||||
"""
|
||||
super()._refresh_element_specific(context, mw, props)
|
||||
|
||||
# ``draw_prepare`` can fire on a freshly recreated GizmoGroup instance
|
||||
# before ``setup_element_specific_gizmos`` has populated the lock /
|
||||
# terminal attributes (Blender 5.x recreates per-region groups on
|
||||
# reload). Bail out cheaply; the next refresh after setup completes
|
||||
# will reposition them correctly.
|
||||
if not hasattr(self, "lock_open_gizmo"):
|
||||
return
|
||||
|
||||
# Single gate for all WALL_MOUNTED_HANDRAIL extras.
|
||||
active = props.is_editing and not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL"
|
||||
|
||||
if not active:
|
||||
self.lock_open_gizmo.hide = True
|
||||
self.lock_closed_gizmo.hide = True
|
||||
self.terminal_gizmo.hide = True
|
||||
return
|
||||
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
view_rotation = self.schematic_view_rotation
|
||||
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
|
||||
|
||||
# ── Lock glyphs for use_manual_supports ──────────────────────────
|
||||
# Sit just above the wall's bottom line, near the centre of the
|
||||
# schematic — visually grouped with the dimension it controls
|
||||
# (support_spacing) without overlapping the arrow tail below.
|
||||
is_manual = bool(props.use_manual_supports)
|
||||
self.lock_open_gizmo.hide = not is_manual
|
||||
self.lock_closed_gizmo.hide = is_manual
|
||||
lock_local = Vector((0.0, 0.05, 0.0))
|
||||
lock_world = anchor + billboard_rot @ view_rotation @ lock_local
|
||||
lock_matrix = gizmo.billboarded_at(lock_world, billboard_rot, 0.09)
|
||||
self.lock_open_gizmo.matrix_basis = lock_matrix
|
||||
self.lock_closed_gizmo.matrix_basis = lock_matrix
|
||||
|
||||
# ── Terminal-type popup gizmo at the right rail end ──────────────
|
||||
# Pushed well past the right wall edge so the icon doesn't crowd
|
||||
# the wall outline or the bracket attach point. At rail height and
|
||||
# rail depth so it reads as "attached to the rail terminal".
|
||||
self.terminal_gizmo.hide = False
|
||||
terminal_local = Vector(
|
||||
(
|
||||
self.SCHEMATIC_MESH_WIDTH_FRAC / 2 + 0.25,
|
||||
self.SCHEMATIC_MESH_RAIL_Y_FRAC,
|
||||
self.schematic_rail_clear(),
|
||||
)
|
||||
)
|
||||
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
|
||||
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
|
||||
|
||||
def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
|
||||
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
|
||||
|
||||
The base class shows the pen gizmo whenever ``is_editing`` is False,
|
||||
which is the case during path-edit too. Allowing the user to click
|
||||
through into parametric edit while the polyline mesh is open in EDIT
|
||||
mode mixes two distinct editing states and leaves a stale draft if
|
||||
they cancel out — block the entry point instead. The operator itself
|
||||
is intentionally not guarded (callers via scripting can still invoke
|
||||
it); this is the UX-level enforcement.
|
||||
|
||||
The cycle icon defaults to the editing icon row (next to validate /
|
||||
cancel) via the parent's positioning. We move it to just above the
|
||||
schematic mesh so it reads as "cycle the railing type *shown here*"
|
||||
— associated with the preview the user is interacting with, not a
|
||||
generic editing button at the bottom of the schematic.
|
||||
"""
|
||||
super().update_editing_gizmos(context, mw, props)
|
||||
if props.is_editing_path:
|
||||
self.pen_gizmo.hide = True
|
||||
|
||||
if props.is_editing and not props.is_editing_path:
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
view_rotation = self.schematic_view_rotation
|
||||
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
|
||||
# Comfortably above the mesh top edge so the icon doesn't crowd
|
||||
# the ``thickness`` / ``clear_width`` dimension callouts that
|
||||
# already sit just above the panel/wall.
|
||||
cycle_local = Vector((0.0, self.SCHEMATIC_MESH_HEIGHT_FRAC + 0.25, 0.0))
|
||||
world_pos = anchor + billboard_rot @ view_rotation @ cycle_local
|
||||
# 30% smaller than the editing-icon-row default (0.30 → 0.21):
|
||||
# the cycle is a tertiary affordance compared to pen/validate/cancel.
|
||||
self.cycle_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, 0.21)
|
||||
|
||||
@classmethod
|
||||
def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh":
|
||||
"""Build a wireframe preview of the railing in schematic-local coordinates.
|
||||
|
||||
FRAMELESS_PANEL renders as a box whose proportions track the bound
|
||||
properties (height / thickness / spacing); WALL_MOUNTED_HANDRAIL
|
||||
renders as a horizontal tube with two L-shaped supports whose
|
||||
proportions track railing_diameter / clear_width / support_spacing.
|
||||
Both are scaled to fit inside ``[-schematic_box_size, +schematic_box_size]``
|
||||
on each axis so the schematic reads the same regardless of absolute
|
||||
property values.
|
||||
|
||||
The mesh is decorative — clicks land on the labeled sliders, not on
|
||||
the preview geometry. See ``BaseSchematicGizmoGroup`` for the
|
||||
draw-handler lifecycle.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
if props.railing_type == "FRAMELESS_PANEL":
|
||||
cls._build_frameless_panel_schematic(bm, props)
|
||||
else:
|
||||
cls._build_wall_mounted_handrail_schematic(bm, props)
|
||||
return bm
|
||||
|
||||
# Schematic-local half-width of the visible gap between the two panel boxes.
|
||||
# Conveys the "spacing" semantic at a glance — the user sees two pickets
|
||||
# separated by air, with the spacing dimension emerging from that gap.
|
||||
SCHEMATIC_MESH_GAP_HALF_WIDTH = 0.05
|
||||
|
||||
@classmethod
|
||||
def _build_frameless_panel_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
|
||||
"""Stylised panel: two wireframe boxes with a visible gap between them.
|
||||
|
||||
The box edges sit at the ``SCHEMATIC_MESH_*_FRAC`` positions
|
||||
(matching where the dimension gizmos anchor), so each dimension line
|
||||
visually starts at the geometry feature it measures. Internal
|
||||
proportions are stable across drags — the actual values are shown
|
||||
through the dimension labels, while the schematic communicates
|
||||
which feature each label refers to. The gap between the two boxes
|
||||
(set by ``SCHEMATIC_MESH_GAP_HALF_WIDTH``) gives the "spacing"
|
||||
dimension a real visual referent.
|
||||
|
||||
Edges are tagged on a string layer so hover-highlight can colour
|
||||
the geometric feature being measured: vertical edges → height,
|
||||
depth edges → thickness. The X-aligned edges along the panel
|
||||
width are untagged (they don't correspond to a single dimension).
|
||||
"""
|
||||
hw = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||
hd = cls.SCHEMATIC_MESH_DEPTH_FRAC / 2
|
||||
h_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
|
||||
gap = cls.SCHEMATIC_MESH_GAP_HALF_WIDTH
|
||||
|
||||
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
|
||||
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
|
||||
|
||||
# Edge index → feature tag for one box. Order matches the (a, b)
|
||||
# tuple order below: bottom ring (4) + top ring (4) + verticals (4).
|
||||
edge_tags_per_box = (
|
||||
b"", # (0,1) bottom-back, X-aligned
|
||||
b"panel_thickness", # (1,2) bottom-right, Z-aligned
|
||||
b"", # (2,3) bottom-front, X-aligned
|
||||
b"panel_thickness", # (3,0) bottom-left, Z-aligned
|
||||
b"", # (4,5) top-back, X-aligned
|
||||
b"panel_thickness", # (5,6) top-right, Z-aligned
|
||||
b"", # (6,7) top-front, X-aligned
|
||||
b"panel_thickness", # (7,4) top-left, Z-aligned
|
||||
b"panel_height", # (0,4) vertical back-left
|
||||
b"panel_height", # (1,5) vertical back-right
|
||||
b"panel_height", # (2,6) vertical front-right
|
||||
b"panel_height", # (3,7) vertical front-left
|
||||
)
|
||||
|
||||
# Build two separate wireframe boxes — one on each side of the central
|
||||
# gap. The boxes share the same Y range (0..h_top) and Z range (±hd)
|
||||
# but split the X range so the gap from -gap to +gap stays empty.
|
||||
for x_left, x_right in ((-hw, -gap), (gap, hw)):
|
||||
corners = [
|
||||
bm.verts.new((x_left, 0.0, -hd)),
|
||||
bm.verts.new((x_right, 0.0, -hd)),
|
||||
bm.verts.new((x_right, 0.0, hd)),
|
||||
bm.verts.new((x_left, 0.0, hd)),
|
||||
bm.verts.new((x_left, h_top, -hd)),
|
||||
bm.verts.new((x_right, h_top, -hd)),
|
||||
bm.verts.new((x_right, h_top, hd)),
|
||||
bm.verts.new((x_left, h_top, hd)),
|
||||
]
|
||||
for tag, (a, b) in zip(
|
||||
edge_tags_per_box,
|
||||
(
|
||||
(0, 1),
|
||||
(1, 2),
|
||||
(2, 3),
|
||||
(3, 0), # bottom ring
|
||||
(4, 5),
|
||||
(5, 6),
|
||||
(6, 7),
|
||||
(7, 4), # top ring
|
||||
(0, 4),
|
||||
(1, 5),
|
||||
(2, 6),
|
||||
(3, 7), # vertical edges
|
||||
),
|
||||
):
|
||||
edge = bm.edges.new((corners[a], corners[b]))
|
||||
if tag:
|
||||
edge[feat_layer] = tag
|
||||
|
||||
@classmethod
|
||||
def _build_wall_mounted_handrail_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
|
||||
"""Stylised wall-mounted handrail: wall outline, hex tube, two L-brackets.
|
||||
|
||||
Three visual elements convey "rail mounted on a wall":
|
||||
|
||||
- **Wall outline** — a wireframe rectangle in the YZ plane at ``z=0``,
|
||||
extending slightly past the rail ends so the wall reads as a
|
||||
surface the rail is *attached to* rather than a coincident frame.
|
||||
- **Handrail tube** — a hexagonal cross-section extruded along ±X
|
||||
at ``z=+clear_s`` (in front of the wall), at ``y=rail_y``.
|
||||
- **L-shaped brackets** at each rail end — from the rail centreline
|
||||
drop a short distance, then run perpendicular back to the wall
|
||||
plane. Mirrors the standard wall-mount bracket geometry: a
|
||||
horizontal arm holding the rail off the wall, a vertical drop
|
||||
attaching to the rail.
|
||||
|
||||
Like ``_build_frameless_panel_schematic``, the schematic uses fixed
|
||||
proportions so the dimension gizmos' anchor points stay aligned
|
||||
with the geometry features regardless of property values.
|
||||
"""
|
||||
half_len = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||
wall_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
|
||||
rail_y = cls.SCHEMATIC_MESH_RAIL_Y_FRAC # rail sits at half wall height
|
||||
radius_s = cls.schematic_rail_radius()
|
||||
clear_s = cls.schematic_rail_clear()
|
||||
|
||||
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
|
||||
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
|
||||
|
||||
# ── Wall outline (rectangle at z=0, slightly wider than the rail) ──
|
||||
# Spans the full schematic height; the rail attaches in the middle,
|
||||
# so the wall reads as "continuing past the rail above and below".
|
||||
# Wall edges stay untagged — they're background context, not a
|
||||
# feature any dimension measures.
|
||||
wall_extra = 0.08
|
||||
wall_x_left = -half_len - wall_extra
|
||||
wall_x_right = half_len + wall_extra
|
||||
wall_corners = [
|
||||
bm.verts.new((wall_x_left, 0.0, 0.0)),
|
||||
bm.verts.new((wall_x_right, 0.0, 0.0)),
|
||||
bm.verts.new((wall_x_right, wall_top, 0.0)),
|
||||
bm.verts.new((wall_x_left, wall_top, 0.0)),
|
||||
]
|
||||
for a, b in ((0, 1), (1, 2), (2, 3), (3, 0)):
|
||||
bm.edges.new((wall_corners[a], wall_corners[b]))
|
||||
|
||||
# ── Handrail tube (hex cross-section in YZ, extruded along X) ──────
|
||||
# Centred on the rail centreline at (±(half_len - rail_inset),
|
||||
# rail_y, +clear_s) — in front of the wall plane at z=0. The tube
|
||||
# is shorter than the wall so the wall visibly extends past it on
|
||||
# both sides; the L-brackets sit at the tube ends, so the leftmost
|
||||
# bracket no longer coincides with the wall's left edge.
|
||||
rail_inset = cls.SCHEMATIC_RAIL_INSET_FRAC
|
||||
rail_x_left = -half_len + rail_inset
|
||||
rail_x_right = half_len - rail_inset
|
||||
segments = 6
|
||||
ring_left, ring_right = [], []
|
||||
for i in range(segments):
|
||||
theta = 2 * math.pi * i / segments
|
||||
dy = math.cos(theta) * radius_s
|
||||
dz = math.sin(theta) * radius_s
|
||||
ring_left.append(bm.verts.new((rail_x_left, rail_y + dy, clear_s + dz)))
|
||||
ring_right.append(bm.verts.new((rail_x_right, rail_y + dy, clear_s + dz)))
|
||||
# All hex-tube edges tagged "rail_tube" so they highlight together
|
||||
# when the railing_diameter dimension is hovered.
|
||||
for i in range(segments):
|
||||
j = (i + 1) % segments
|
||||
e_left = bm.edges.new((ring_left[i], ring_left[j]))
|
||||
e_right = bm.edges.new((ring_right[i], ring_right[j]))
|
||||
e_axial = bm.edges.new((ring_left[i], ring_right[i]))
|
||||
e_left[feat_layer] = b"rail_tube"
|
||||
e_right[feat_layer] = b"rail_tube"
|
||||
e_axial[feat_layer] = b"rail_tube"
|
||||
|
||||
# ── L-brackets at each rail end (rail → drop → wall) ───────────────
|
||||
# Bracket attach points follow the rail ends, so they're pulled
|
||||
# inward by ``rail_inset`` from the wall edges. From the rail
|
||||
# centreline, drop ``bracket_drop`` in Y, then run perpendicular
|
||||
# back to the wall plane (z=0). The L shape reads as a wall-mount
|
||||
# bracket under the 3/4 tilt. Both bracket segments tagged
|
||||
# "bracket" so they highlight when clear_width OR support_spacing
|
||||
# is hovered (both dimensions measure features of the supports).
|
||||
bracket_drop = 0.06
|
||||
for x in (rail_x_left, rail_x_right):
|
||||
v_rail = bm.verts.new((x, rail_y, clear_s))
|
||||
v_corner = bm.verts.new((x, rail_y - bracket_drop, clear_s))
|
||||
v_wall = bm.verts.new((x, rail_y - bracket_drop, 0.0))
|
||||
e1 = bm.edges.new((v_rail, v_corner))
|
||||
e2 = bm.edges.new((v_corner, v_wall))
|
||||
e1[feat_layer] = b"bracket"
|
||||
e2[feat_layer] = b"bracket"
|
||||
|
||||
|
||||
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.flip_railing_path_order"
|
||||
bl_label = "Flip Railing Path Order"
|
||||
@@ -511,6 +1137,16 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
[o.select_set(False) for o in context.selected_objects if o != obj]
|
||||
assert obj
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
|
||||
# Auto-commit any in-progress parametric draft before switching to
|
||||
# path-edit. ``set_props_kwargs_from_ifc_data`` a few lines below
|
||||
# overwrites props with the pset's stored values — without committing
|
||||
# first, anything the user dragged on a dimension gizmo (height,
|
||||
# diameter, …) would be silently discarded the moment path-edit
|
||||
# starts.
|
||||
if props.is_editing:
|
||||
tool.Parametric.commit_object_draft(obj, "bim.finish_editing_railing")
|
||||
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
||||
# required since we could load pset from .ifc and BIMRoofProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
from math import cos, pi, radians, tan
|
||||
from typing import Any, Literal, Union
|
||||
from math import atan2, cos, degrees, pi, radians, tan
|
||||
from typing import Any, ClassVar, Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -32,8 +32,11 @@ from mathutils import Quaternion, Vector
|
||||
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
|
||||
from bonsai.bim.module.model.data import RoofData, refresh
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
|
||||
@@ -210,7 +213,13 @@ def generate_hipped_roof_bmesh(
|
||||
|
||||
new_verts = [bm.verts.new(v) for v in verts]
|
||||
new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges]
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
|
||||
# Skip degenerate faces. ``bpypolyskel.polygonize`` can emit a face whose
|
||||
# vertex list contains the same index twice on certain footprint /
|
||||
# slope combinations (the straight-skeleton collapses two ridge events
|
||||
# onto the same vertex). ``bm.faces.new`` rejects those with
|
||||
# ``found the same (BMVert) used multiple times``; dropping them keeps
|
||||
# the rest of the roof intact instead of aborting the whole rebuild.
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces if len(set(face)) == len(face)]
|
||||
|
||||
if mode == "HEIGHT": # Calculate the angle we ended up with.
|
||||
new_faces[0].normal_update()
|
||||
@@ -396,6 +405,11 @@ def generate_hipped_roof_bmesh(
|
||||
if is_internal:
|
||||
faces_to_delete.add(face)
|
||||
bmesh.ops.delete(bm, geom=list(faces_to_delete), context="FACES")
|
||||
# Final pass: ``remove_doubles`` + internal-face deletion above can leave
|
||||
# the bottom slab faces flipped at low slopes, where the kernel's
|
||||
# "outward" inference becomes ambiguous on near-flat geometry. Recompute
|
||||
# once more on the final topology so the eave plane points down.
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||
return bm
|
||||
|
||||
|
||||
@@ -608,61 +622,169 @@ 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.Parametric.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"}
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Rebuild the roof bmesh from the just-restored draft props so the
|
||||
viewport reverts to the pre-edit geometry. Same helper the modal
|
||||
edits use, just driven by the cancelled props instead of in-flight
|
||||
drag values."""
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
|
||||
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
|
||||
"roof",
|
||||
_RoofEditMixin,
|
||||
labels=(
|
||||
("Enable Editing Roof", ""),
|
||||
("Finish Editing Roof", ""),
|
||||
("Cancel Editing Roof", ""),
|
||||
),
|
||||
module_name=__name__,
|
||||
)
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
|
||||
path_data = pset_data["data_dict"]["path_data"]
|
||||
# Fixed horizontal run for the slope gizmo: the draggable value is the
|
||||
# vertical rise at this distance from the anchor, in the rise/run convention.
|
||||
_ROOF_SLOPE_REFERENCE_RUN = 1.0
|
||||
# One degree shy of vertical; avoids tan() blow-up when the user drags the
|
||||
# rise handle past the gizmo's anchor.
|
||||
_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
|
||||
|
||||
roof_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
roof_data["path_data"] = path_data
|
||||
props.is_editing = False
|
||||
|
||||
update_bbim_roof_pset(element, roof_data)
|
||||
update_roof_modifier_ifc_data(context)
|
||||
return {"FINISHED"}
|
||||
def _roof_has_openings() -> bool:
|
||||
"""``visible_when`` predicate for the toggle_openings idle slot. True iff
|
||||
the active object's IFC element exposes a non-empty HasOpenings inverse."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Geometry.has_openings(element)
|
||||
|
||||
|
||||
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
|
||||
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
|
||||
|
||||
bl_idname = "bim.cycle_roof_generation_method"
|
||||
bl_label = "Cycle Roof Generation Method"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_roof
|
||||
props_getter = tool.Model.get_roof_props
|
||||
type_literal = tool.Model.RoofGenerationMethod
|
||||
type_attr = "generation_method"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_idname = "OBJECT_GGT_bim_roof_edition"
|
||||
bl_label = "Roof Editing Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
enable_editing_operator = "bim.enable_editing_roof"
|
||||
finish_editing_operator = "bim.finish_editing_roof"
|
||||
cancel_editing_operator = "bim.cancel_editing_roof"
|
||||
cycle_type_operator = "bim.cycle_roof_generation_method"
|
||||
|
||||
# Positions for all three dimensions are set per-frame by the position
|
||||
# override below; no static ``matrix_position`` is needed.
|
||||
dimension_gizmo_props = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="height",
|
||||
axis=(0, 0, 1),
|
||||
min_value=0.01,
|
||||
visibility_condition=lambda p: p.generation_method == "HEIGHT",
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="angle",
|
||||
axis=(0, 0, 1),
|
||||
prop_name="Slope",
|
||||
min_value=0.0,
|
||||
visibility_condition=lambda p: p.generation_method == "ANGLE",
|
||||
compute_value=lambda p: tan(p.angle) * _ROOF_SLOPE_REFERENCE_RUN,
|
||||
apply_value=lambda p, rise: setattr(
|
||||
p, "angle", min(_ROOF_MAX_SLOPE_ANGLE, max(0.0, atan2(rise, _ROOF_SLOPE_REFERENCE_RUN)))
|
||||
),
|
||||
text_formatter=lambda p, rise: (f"{tool.Unit.format_distance(rise)} ({degrees(p.angle):.1f}°)"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="roof_thickness",
|
||||
axis=(0, 0, -1),
|
||||
min_value=0.001,
|
||||
# The line shows the perpendicular slab thickness (matching the
|
||||
# pset value and the drag delta); the true vertical span is
|
||||
# ``roof_thickness / cos(angle)``, longer than what is drawn.
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_roof_props
|
||||
gizmo_pref_name = "roof"
|
||||
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="toggle_openings",
|
||||
gizmo_idname="VIEW3D_GT_add_opening",
|
||||
operator="bim.toggle_host_openings",
|
||||
visible_when=lambda gg: _roof_has_openings(),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
|
||||
"""Anchor every dimension gizmo at the object origin. Each gizmo's
|
||||
declared axis (height/slope along +Z, thickness along -Z) separates
|
||||
them in 3D so they don't visually collide despite sharing a
|
||||
position; the height + slope gizmos themselves are mutually
|
||||
exclusive via ``visibility_condition`` on ``generation_method``."""
|
||||
origin = Vector((0.0, 0.0, 0.0))
|
||||
self.set_dimension_gizmo_position("height", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
|
||||
|
||||
def get_element_height(self, props) -> float: # noqa: ARG002
|
||||
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
|
||||
cancel / cycle row anchors visibly above sloped or stepped roof
|
||||
bodies rather than at the parametric ``props.height`` which may not
|
||||
match the rendered apex on ANGLE-generation roofs."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None or not getattr(obj, "bound_box", None):
|
||||
return 1.0
|
||||
return max(c[2] for c in obj.bound_box)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -271,7 +271,7 @@ class DumbSlabPlaner:
|
||||
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
|
||||
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
|
||||
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
|
||||
existing_x_angle = obj.rotation_euler.x
|
||||
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
|
||||
@@ -620,6 +620,14 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
obj = context.active_object
|
||||
|
||||
# Commit any in-progress parametric (gizmo) draft on this object
|
||||
# before switching to profile-edit. Otherwise the in-memory draft
|
||||
# state is overwritten when the profile mesh is imported below,
|
||||
# silently discarding the user's pending dimension edits.
|
||||
if feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.commit_object_draft(obj, feature.finish_op)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
@@ -991,3 +999,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
tool.Model.recalculate_walls(walls)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Open the slab disconnect-access mode. Pure UI toggle: flips
|
||||
``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect
|
||||
gizmos surface on the slab. ``tool.Ifc.Operator`` base because the
|
||||
parametric framework's universal dispatcher routes through
|
||||
``tool.Parametric.run_bim_op``, which only accepts that subclass for
|
||||
undo-safe lifecycle. No IFC mutation."""
|
||||
|
||||
bl_idname = "bim.enable_editing_slab"
|
||||
bl_label = "Edit Slab Connections"
|
||||
bl_description = "Show disconnect icons for every wall clipped to this slab"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return element is not None and element.is_a("IfcSlab")
|
||||
|
||||
def _execute(self, context):
|
||||
context.active_object.BIMSlabProperties.is_editing = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Close the slab disconnect-access mode."""
|
||||
|
||||
bl_idname = "bim.cancel_editing_slab"
|
||||
bl_label = "Close Slab Edit"
|
||||
bl_description = "Hide the slab disconnect icons"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return element is not None and element.is_a("IfcSlab")
|
||||
|
||||
def _execute(self, context):
|
||||
context.active_object.BIMSlabProperties.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Close the slab disconnect-access mode. Same body as Cancel — slab
|
||||
edit is a pure UI gate with no IFC draft to commit; the framework
|
||||
requires both ``bim.finish_editing_<name>`` and
|
||||
``bim.cancel_editing_<name>`` to exist by name convention."""
|
||||
|
||||
bl_idname = "bim.finish_editing_slab"
|
||||
bl_label = "Finish Slab Edit"
|
||||
bl_description = "Hide the slab disconnect icons"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return element is not None and element.is_a("IfcSlab")
|
||||
|
||||
def _execute(self, context):
|
||||
context.active_object.BIMSlabProperties.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +31,13 @@ from mathutils import Matrix, Vector
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
COLOR_GREEN,
|
||||
COLOR_RED,
|
||||
DimensionGizmoConfig,
|
||||
IconSlot,
|
||||
)
|
||||
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
|
||||
from bonsai.tool.numeric_input import (
|
||||
IntegerInputState,
|
||||
run_integer_input_modal,
|
||||
@@ -37,7 +45,7 @@ from bonsai.tool.numeric_input import (
|
||||
)
|
||||
|
||||
V_ = tool.Blender.V_
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from bmesh.types import BMVert
|
||||
from bpy.props import IntProperty
|
||||
@@ -262,7 +270,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 +279,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
# update IfcStairFlight properties
|
||||
update_ifc_stair_props(obj)
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -376,6 +384,20 @@ class AdjustStairTreads(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator):
|
||||
"""Popup-dialog entry point for typing a new ``number_of_treads`` value.
|
||||
Bound to the world-space ``xN`` count label in the stair edit row."""
|
||||
|
||||
bl_idname = "bim.input_stair_treads"
|
||||
bl_label = "Set Number of Treads"
|
||||
bl_description = "Type the number of treads for this stair"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
number_of_treads: IntProperty(name="Number of Treads", default=1, min=1)
|
||||
attr_name = "number_of_treads"
|
||||
props_getter = staticmethod(tool.Model.get_stair_props)
|
||||
|
||||
|
||||
class SetStairTreads(bpy.types.Operator):
|
||||
"""Set the number of treads to a specific value."""
|
||||
|
||||
@@ -421,20 +443,20 @@ class SetStairTreads(bpy.types.Operator):
|
||||
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
|
||||
|
||||
|
||||
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through stair types. Shift+click to cycle in reverse."""
|
||||
class PickStairType(bpy.types.Operator, PickTypeMixin):
|
||||
"""Pick a stair type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_stair_type"
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_idname = "bim.pick_stair_type"
|
||||
bl_label = "Pick Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = "get_stair_props"
|
||||
props_getter = tool.Model.get_stair_props
|
||||
type_literal = tool.Model.StairType
|
||||
type_attr = "stair_type"
|
||||
skip_element_check = True
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
|
||||
@@ -460,20 +482,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
# === Stair-Specific Icon Layout (meters) ===
|
||||
# Additional icons for stair editing, positioned after standard icons:
|
||||
# [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
|
||||
ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon
|
||||
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
|
||||
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
|
||||
# === Stair-Specific Icon Layout ===
|
||||
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus]
|
||||
# The base class assigns X positions from ``feature_slots`` tuple order —
|
||||
# adding an icon is a one-line append, no hardcoded X constant.
|
||||
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
|
||||
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
|
||||
ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label
|
||||
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
|
||||
|
||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="tread_lock",
|
||||
gizmo_idname="VIEW3D_GT_lock",
|
||||
variants=("open", "closed"),
|
||||
operator="bim.toggle_stair_property",
|
||||
color=(1.0, 1.0, 1.0),
|
||||
operator_props=(("property_name", "custom_tread_lock"),),
|
||||
),
|
||||
IconSlot(name="tread_count_label", placeholder=True),
|
||||
IconSlot(
|
||||
name="plus",
|
||||
gizmo_idname="VIEW3D_GT_plus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_GREEN,
|
||||
operator_props=(("increment", 1),),
|
||||
),
|
||||
IconSlot(
|
||||
name="minus",
|
||||
gizmo_idname="VIEW3D_GT_minus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_RED,
|
||||
operator_props=(("increment", -1),),
|
||||
),
|
||||
)
|
||||
|
||||
enable_editing_operator = "bim.enable_editing_stair"
|
||||
finish_editing_operator = "bim.finish_editing_stair"
|
||||
cancel_editing_operator = "bim.cancel_editing_stair"
|
||||
cycle_type_operator = "bim.cycle_stair_type"
|
||||
pick_type_operator = "bim.pick_stair_type"
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for stair icon positioning.
|
||||
@@ -578,83 +627,91 @@ 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
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_stair(element)
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create stair-specific icon gizmos (lock, plus, minus)."""
|
||||
self.lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
self.COLOR_BLUE,
|
||||
"""Create the total-length lock as an open/closed pair plus the
|
||||
``xN`` tread-count label. Lock click toggles
|
||||
``props.total_length_lock``; the per-frame update hook picks which
|
||||
member is visible. Anchored to the stair's far X end (not the edit
|
||||
row) so it's positioned by ``_update_lock_gizmo_position`` rather
|
||||
than the toolbar slot system.
|
||||
|
||||
The count label binds to ``bim.input_stair_treads`` (popup dialog)
|
||||
for click-to-type input and sits at the X reserved by the
|
||||
``tread_count_label`` placeholder slot in ``feature_slots``."""
|
||||
self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.total_length_lock",
|
||||
self.COLOR_BLUE,
|
||||
property_name="total_length_lock",
|
||||
)
|
||||
self.tread_lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
(1.0, 1.0, 1.0),
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.custom_tread_lock",
|
||||
property_name="custom_tread_lock",
|
||||
)
|
||||
self.plus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
|
||||
)
|
||||
self.minus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
|
||||
)
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label")
|
||||
self.tread_count_label_gizmo.use_draw_scale = False
|
||||
self.tread_count_label_gizmo.color = default_color
|
||||
self.tread_count_label_gizmo.color_highlight = highlight_color
|
||||
self.tread_count_label_gizmo.alpha = 0.8
|
||||
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
|
||||
|
||||
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> 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."""
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
|
||||
return # Hidden, skip positioning
|
||||
|
||||
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
|
||||
|
||||
total_run = props.get_total_run()
|
||||
local_transform = (
|
||||
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
|
||||
@ billboard_rot
|
||||
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
|
||||
)
|
||||
self.lock_gizmo.matrix_basis = mw @ local_transform
|
||||
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Show the open/closed total-length lock variant matching
|
||||
``props.total_length_lock``. Positioning is handled per-frame by
|
||||
the dimension-positioning hook."""
|
||||
if not hasattr(self, "total_length_lock_open_gizmo"):
|
||||
return
|
||||
if not props.is_editing:
|
||||
self.total_length_lock_open_gizmo.hide = True
|
||||
self.total_length_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.total_length_lock_open_gizmo.hide = props.total_length_lock
|
||||
self.total_length_lock_closed_gizmo.hide = not props.total_length_lock
|
||||
|
||||
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "tread_lock_gizmo"):
|
||||
"""Show the open/closed lock variant matching ``props.custom_tread_lock``.
|
||||
|
||||
Both pair members share an X position (set by the base's slot
|
||||
positioning); this picks which one is visible per frame so a state
|
||||
flip can't reveal both at once."""
|
||||
if not hasattr(self, "tread_lock_open_gizmo"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
|
||||
if not props.is_editing:
|
||||
self.tread_lock_open_gizmo.hide = True
|
||||
self.tread_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.tread_lock_open_gizmo.hide = props.custom_tread_lock
|
||||
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
|
||||
|
||||
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
|
||||
"""Update visibility of the +/- tread count gizmos and the ``xN``
|
||||
label. Positioning is handled in ``_update_editing_icon_positions``."""
|
||||
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
|
||||
# Minus has additional condition: number_of_treads > 1
|
||||
self.update_gizmo_visibility(
|
||||
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
|
||||
)
|
||||
self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
|
||||
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()
|
||||
|
||||
@@ -725,10 +782,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
billboard_rot: Matrix,
|
||||
total_run: float,
|
||||
) -> None:
|
||||
"""Update lock gizmo position based on Y view direction."""
|
||||
"""Update lock gizmo pair position based on Y view direction. Writes
|
||||
the matrix on both members so a state flip can't reveal a stale pose."""
|
||||
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
|
||||
self.set_icon_gizmo_position(
|
||||
"lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"total_length_lock_open_gizmo",
|
||||
"total_length_lock_closed_gizmo",
|
||||
mw,
|
||||
total_run + self.ICON_Z_OFFSET,
|
||||
y_pos,
|
||||
@@ -740,30 +799,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
def _update_editing_icon_positions(
|
||||
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix
|
||||
) -> None:
|
||||
"""Update editing icon positions, flipping Y based on viewing angle."""
|
||||
"""Reposition the editing icons at stair's view-dependent Y. The base
|
||||
class's update_editing_gizmos already placed them at the default
|
||||
``get_icon_y_offset`` Y — this overrides with the stair-specific
|
||||
``get_icon_y_for_view`` flip so the icons land on the side the
|
||||
camera is looking from."""
|
||||
if not props.is_editing:
|
||||
return
|
||||
|
||||
icon_z = props.height + self.ICON_Z_OFFSET
|
||||
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
|
||||
slot_x = self._slot_x_positions()
|
||||
|
||||
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position(
|
||||
"cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"tread_lock_open_gizmo",
|
||||
"tread_lock_closed_gizmo",
|
||||
mw,
|
||||
self.ICON_TREAD_LOCK_X,
|
||||
slot_x["tread_lock"],
|
||||
y_pos,
|
||||
icon_z - self.EDITING_ICON_SCALE / 2,
|
||||
billboard_rot,
|
||||
scale=self.EDITING_ICON_SCALE,
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.tread_count_label_gizmo.set_count(int(props.number_of_treads))
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_count_label_gizmo",
|
||||
mw,
|
||||
slot_x["tread_count_label"],
|
||||
y_pos,
|
||||
icon_z,
|
||||
billboard_rot,
|
||||
scale=self.ICON_COUNT_LABEL_SCALE,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
from bpy.types import Panel
|
||||
|
||||
import bonsai.bim
|
||||
@@ -237,11 +238,11 @@ class BIM_PT_array(bpy.types.Panel):
|
||||
|
||||
for i, array in enumerate(ArrayData.data["parameters"]["data_dict"]):
|
||||
box = self.layout.box()
|
||||
if props.is_editing == i:
|
||||
if props.editing_item_index == i:
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "count", icon="MOD_ARRAY")
|
||||
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
|
||||
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
|
||||
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
|
||||
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "method")
|
||||
row = box.row(align=True)
|
||||
@@ -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.Parametric.is_wall(element)
|
||||
|
||||
def draw(self, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return
|
||||
props = tool.Model.get_wall_props(obj)
|
||||
row = self.layout.row(align=True)
|
||||
if props.is_editing:
|
||||
row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
|
||||
row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
|
||||
else:
|
||||
row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
|
||||
|
||||
|
||||
class BIM_PT_sverchok(bpy.types.Panel):
|
||||
bl_label = "Sverchok"
|
||||
bl_idname = "BIM_PT_sverchok"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Four wall-offset dimension gizmos (left / right / top / bottom) shared by door and
|
||||
window edit gizmo groups — both fillings sit in a LAYER2 wall and the offset math is
|
||||
identical.
|
||||
|
||||
The compute side returns a *signed* value on the X axis (negative when the filling
|
||||
is 180°-flipped onto the wall's opposite face) so the gizmo framework auto-flips
|
||||
the rendered arrow; the apply side takes ``abs(value)`` because the user-facing
|
||||
offset is always positive. Z-axis values are unsigned in both directions.
|
||||
|
||||
Fillings are assumed to align with the wall's local X axis to within ±90° — the
|
||||
parametric door/window construction path enforces this, and the X-sign math
|
||||
falls back to +1 if ``col[0].x`` lands on the ambiguous zero (filling rotated
|
||||
exactly 90° in the wall plane).
|
||||
|
||||
Every public entry point falls back to a safe no-op when the host-wall chain
|
||||
cannot be resolved: reads return 0.0, writes do nothing, and gizmo anchors
|
||||
return a filling-relative position. This keeps the gizmos non-crashing when a
|
||||
filling momentarily loses its host (e.g. mid-edit, partially-loaded files).
|
||||
|
||||
``_GEOM_CACHE`` is module-scoped and persists across tests — tests must call
|
||||
``clear_caches()`` between cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, NamedTuple, Protocol
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
|
||||
class FillingProps(Protocol):
|
||||
"""Structural subset of door/window props this module touches."""
|
||||
|
||||
id_data: bpy.types.Object
|
||||
overall_width: float
|
||||
overall_height: float
|
||||
|
||||
|
||||
# Wall-local frame axis indices. Y (depth) is unused — fillings sit on the wall's centreline.
|
||||
_AXIS_X = 0
|
||||
_AXIS_Z = 2
|
||||
|
||||
|
||||
class _HostWallGeom(NamedTuple):
|
||||
"""Cached host-wall geometry in SI metres, wall-local frame. ``height`` is
|
||||
the vertical projection (already accounts for slanted extrusions)."""
|
||||
|
||||
wall_obj: bpy.types.Object
|
||||
height: float
|
||||
axis_min_x: float
|
||||
axis_max_x: float
|
||||
|
||||
|
||||
class _AxisExtent(NamedTuple):
|
||||
"""``[low, high]`` interval on one wall-local axis; low = near end.
|
||||
|
||||
``x_sign`` is +1 / -1 for an X-axis filling extent only (carries the
|
||||
180° auto-flip); always 1.0 elsewhere."""
|
||||
|
||||
low: float
|
||||
high: float
|
||||
x_sign: float = 1.0
|
||||
|
||||
|
||||
class _Edge(NamedTuple):
|
||||
"""Wall edge a gizmo measures to. ``is_max_end=True`` picks right/top, else left/bottom."""
|
||||
|
||||
axis_index: int
|
||||
is_max_end: bool
|
||||
|
||||
|
||||
_LEFT = _Edge(axis_index=_AXIS_X, is_max_end=False)
|
||||
_RIGHT = _Edge(axis_index=_AXIS_X, is_max_end=True)
|
||||
_BOTTOM = _Edge(axis_index=_AXIS_Z, is_max_end=False)
|
||||
_TOP = _Edge(axis_index=_AXIS_Z, is_max_end=True)
|
||||
|
||||
|
||||
# Avoids repeating the host-wall chain walk + LAYER2 geometry read per gizmo per frame.
|
||||
_GEOM_CACHE = tool.Parametric.GenerationKeyedCache()
|
||||
|
||||
|
||||
def clear_caches() -> None:
|
||||
_GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
"""Cached host-wall geometry for a filling, or ``None`` if any link in
|
||||
filling → opening → wall → LAYER2 extrusion → scene-object resolution breaks."""
|
||||
return _GEOM_CACHE.get_or_compute(filling_obj.name, lambda: _compute_host_wall_geom(filling_obj))
|
||||
|
||||
|
||||
def _compute_host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
element = tool.Ifc.get_entity(filling_obj)
|
||||
if not element:
|
||||
return None
|
||||
host_wall = tool.Spatial.get_host_wall(element)
|
||||
if not host_wall:
|
||||
return None
|
||||
wall_obj = tool.Ifc.get_object(host_wall)
|
||||
length_height = tool.Wall.get_length_and_height(host_wall)
|
||||
axis_extent = tool.Wall.get_axis_local_extent(host_wall)
|
||||
# x_angle is None for non-LAYER2 walls — gates entry; the value itself is unused.
|
||||
if not (wall_obj and length_height and axis_extent and tool.Wall.get_x_angle(host_wall) is not None):
|
||||
return None
|
||||
_, height = length_height
|
||||
axis_min_x, axis_max_x = axis_extent
|
||||
return _HostWallGeom(wall_obj=wall_obj, height=height, axis_min_x=axis_min_x, axis_max_x=axis_max_x)
|
||||
|
||||
|
||||
def _filling_axis_extent(props: FillingProps, host_wall_obj: bpy.types.Object, axis_index: int) -> _AxisExtent:
|
||||
"""Filling footprint on the wall's local axis.
|
||||
|
||||
X-axis extent carries the filling's orientation sign (180° flip onto
|
||||
the opposite face) in ``x_sign``."""
|
||||
filling_in_wall = host_wall_obj.matrix_world.inverted() @ props.id_data.matrix_world
|
||||
origin = filling_in_wall.translation[axis_index]
|
||||
if axis_index == _AXIS_X:
|
||||
# col[0].x is the X-component of the filling's local X axis in the wall-local frame:
|
||||
# +1 when filling's +X aligns with wall's +X, -1 after a 180° Z-flip.
|
||||
x_sign = 1.0 if filling_in_wall.col[0].x >= 0.0 else -1.0
|
||||
signed_width = x_sign * props.overall_width
|
||||
return _AxisExtent(origin + min(0.0, signed_width), origin + max(0.0, signed_width), x_sign)
|
||||
return _AxisExtent(origin, origin + props.overall_height)
|
||||
|
||||
|
||||
def _wall_axis_extent(geom: _HostWallGeom, axis_index: int) -> _AxisExtent:
|
||||
"""Wall span on one local axis: X = IFC axis-line endpoints (not mesh bound-box,
|
||||
which drifts on trimmed walls); Z = 0 → wall height."""
|
||||
if axis_index == _AXIS_X:
|
||||
return _AxisExtent(geom.axis_min_x, geom.axis_max_x)
|
||||
return _AxisExtent(0.0, geom.height)
|
||||
|
||||
|
||||
def _offset_from_extents(filling: _AxisExtent, wall: _AxisExtent, is_max_end: bool) -> float:
|
||||
"""Distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
if is_max_end:
|
||||
return wall.high - filling.high
|
||||
return filling.low - wall.low
|
||||
|
||||
|
||||
def _translate_along_wall_axis(
|
||||
props: FillingProps, host_wall_obj: bpy.types.Object, delta: float, axis_index: int
|
||||
) -> None:
|
||||
"""Shift the filling by ``delta`` SI metres along the wall's local axis. Drag
|
||||
operates in the filling's intent frame, not Blender's world frame, so a rotated
|
||||
host wall still tracks correctly."""
|
||||
if delta == 0.0:
|
||||
return
|
||||
direction_world = host_wall_obj.matrix_world.to_3x3().col[axis_index].normalized()
|
||||
props.id_data.matrix_world.translation = props.id_data.matrix_world.translation + direction_world * delta
|
||||
|
||||
|
||||
def _get_offset(props: FillingProps, edge: _Edge) -> float:
|
||||
"""SI distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _set_offset(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Translate the filling so its offset to ``edge`` becomes ``max(0, value)`` SI metres.
|
||||
Max-end edges (right/top) translate in the opposite direction of near-end edges."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return
|
||||
current = _get_offset(props, edge)
|
||||
target = max(0.0, value)
|
||||
delta = (current - target) if edge.is_max_end else (target - current)
|
||||
_translate_along_wall_axis(props, geom.wall_obj, delta, edge.axis_index)
|
||||
|
||||
|
||||
def has_host_wall(props: FillingProps) -> bool:
|
||||
"""True when the filling resolves to a LAYER2 host wall present in the scene."""
|
||||
return _host_wall_geom(props.id_data) is not None
|
||||
|
||||
|
||||
def _edge_position(props: FillingProps, edge: _Edge) -> Vector:
|
||||
"""Gizmo anchor in filling-local space, at the wall edge, pointing toward the filling."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
if edge.axis_index == _AXIS_X:
|
||||
return Vector((0.0, 0.0, props.overall_height / 2))
|
||||
return Vector((props.overall_width / 2, 0.0, props.overall_height if edge.is_max_end else 0.0))
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
edge_value = wall.high if edge.is_max_end else wall.low
|
||||
if edge.axis_index == _AXIS_X:
|
||||
wall_edge_world = geom.wall_obj.matrix_world @ Vector((edge_value, 0.0, 0.0))
|
||||
pos = props.id_data.matrix_world.inverted() @ wall_edge_world
|
||||
return Vector((pos.x, 0.0, props.overall_height / 2))
|
||||
# LAYER2 wall matrix_world is upright, so wall-local Z and filling-local Z differ
|
||||
# only by the filling's Z origin in the wall frame.
|
||||
filling_z_in_wall = _filling_axis_extent(props, geom.wall_obj, axis_index=_AXIS_Z).low
|
||||
return Vector((props.overall_width / 2, 0.0, edge_value - filling_z_in_wall))
|
||||
|
||||
|
||||
def _compute_value(props: FillingProps, edge: _Edge) -> float:
|
||||
"""Renderer-side value. X-axis edges return a signed value so the gizmo's
|
||||
auto-flip kicks in for fillings on the wall's opposite face; Z-axis returns unsigned."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return filling.x_sign * _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _apply_value(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Drag-end commit; X-axis takes ``abs(value)`` since the negative sign in compute
|
||||
is a rendering hint only (user-facing offset is always positive)."""
|
||||
if edge.axis_index == _AXIS_X:
|
||||
_set_offset(props, edge, abs(value))
|
||||
else:
|
||||
_set_offset(props, edge, value)
|
||||
|
||||
|
||||
# attr_name identifies the gizmo within its group; values flow through
|
||||
# compute/apply, not via a registered property.
|
||||
WALL_OFFSET_GIZMO_CONFIGS: list[DimensionGizmoConfig] = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_left",
|
||||
axis=(1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _LEFT),
|
||||
apply_value=lambda p, v: _apply_value(p, _LEFT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _LEFT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_right",
|
||||
axis=(-1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _RIGHT),
|
||||
apply_value=lambda p, v: _apply_value(p, _RIGHT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _RIGHT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_bottom",
|
||||
axis=(0, 0, 1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _BOTTOM),
|
||||
apply_value=lambda p, v: _apply_value(p, _BOTTOM, v),
|
||||
matrix_position=lambda p: _edge_position(p, _BOTTOM),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_top",
|
||||
axis=(0, 0, -1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _TOP),
|
||||
apply_value=lambda p, v: _apply_value(p, _TOP, v),
|
||||
matrix_position=lambda p: _edge_position(p, _TOP),
|
||||
),
|
||||
]
|
||||
@@ -39,6 +39,8 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -482,90 +484,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.Parametric.is_window(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_window_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_window_modifier_representation(context)
|
||||
|
||||
|
||||
class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
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):
|
||||
@@ -587,20 +552,20 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available window types. Shift+click to cycle in reverse."""
|
||||
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a window type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_window_type"
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_idname = "bim.pick_window_type"
|
||||
bl_label = "Pick Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "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"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
|
||||
@@ -638,7 +603,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_window"
|
||||
finish_editing_operator = "bim.finish_editing_window"
|
||||
cancel_editing_operator = "bim.cancel_editing_window"
|
||||
cycle_type_operator = "bim.cycle_window_type"
|
||||
pick_type_operator = "bim.pick_window_type"
|
||||
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
dimension_gizmo_props = [
|
||||
@@ -779,14 +744,15 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
),
|
||||
# lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support
|
||||
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
props_getter = "get_window_props"
|
||||
props_getter = tool.Model.get_window_props
|
||||
gizmo_pref_name = "window"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for window icon positioning.
|
||||
|
||||
@@ -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,20 @@ class EditObjectUI:
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
def draw_regen_operations(cls, row):
|
||||
custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id
|
||||
|
||||
if AuthoringData.data["is_regenable_element"]:
|
||||
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
|
||||
description = "Recalculate Element Geometry\nHotkey: S G"
|
||||
op.hotkey = "S_G"
|
||||
op.description = description.strip()
|
||||
def draw_regen_operations(cls, row, ui_context):
|
||||
# ``AuthoringData.load`` flips ``is_loaded`` at entry as a recursion
|
||||
# guard, so a partial load (any computation along the way raising)
|
||||
# leaves the tail keys unset. ``.get()`` keeps the header draw alive
|
||||
# until the underlying failure is investigated.
|
||||
if AuthoringData.data.get("is_regenable_element"):
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
|
||||
|
||||
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 +1300,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":
|
||||
@@ -1315,7 +1321,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.recalculate_profile()
|
||||
elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"):
|
||||
bpy.ops.bim.recalculate_fill()
|
||||
elif self.active_class in ("IfcSpace"):
|
||||
elif self.active_class in ("IfcSpace",):
|
||||
bpy.ops.bim.generate_space()
|
||||
|
||||
def hotkey_S_M(self):
|
||||
@@ -1442,10 +1448,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.enable_editing_extrusion_axis()
|
||||
|
||||
def hotkey_A_O(self):
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
bpy.ops.bim.toggle_host_openings()
|
||||
|
||||
def hotkey_C_E(self):
|
||||
if not bpy.context.selected_objects:
|
||||
|
||||
@@ -20,7 +20,6 @@ import blf
|
||||
import bpy
|
||||
import gpu
|
||||
import ifcopenshell.util.element
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras import view3d_utils
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
@@ -28,12 +27,6 @@ from mathutils import Vector
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
def create_bounding_box(objs):
|
||||
# Initialize the bounding box coordinates
|
||||
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
|
||||
@@ -79,26 +72,8 @@ def create_bounding_box(objs):
|
||||
return indices, edges
|
||||
|
||||
|
||||
class NestDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
class NestDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_nest"
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
@@ -154,14 +129,6 @@ class NestDecorator:
|
||||
shader.uniform_float("u_Scale", 25)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_nest(self, context: bpy.types.Context) -> None:
|
||||
props = tool.Nest.get_nest_props()
|
||||
if props.in_nest_mode:
|
||||
@@ -226,35 +193,11 @@ class NestDecorator:
|
||||
self.draw_custom_batch(line, decorator_color_unselected)
|
||||
|
||||
|
||||
class NestModeDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_name, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_empty, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
class NestModeDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_nest_name", "POST_PIXEL"),
|
||||
("draw_nest_empty", "POST_VIEW"),
|
||||
)
|
||||
|
||||
def draw_nest_name(self, context):
|
||||
if context.mode == "EDIT_MESH":
|
||||
|
||||
@@ -21,6 +21,7 @@ import bpy
|
||||
from . import operator, prop, ui
|
||||
|
||||
classes = (
|
||||
operator.AddIfcPatchPreset,
|
||||
operator.ExecuteIfcPatch,
|
||||
operator.ExtractSelectedElements,
|
||||
operator.RunMigratePatch,
|
||||
@@ -28,6 +29,7 @@ classes = (
|
||||
operator.SelectIfcPatchOutput,
|
||||
operator.UpdateIfcPatchArguments,
|
||||
prop.BIMPatchProperties,
|
||||
ui.BIM_MT_ifc_patch_presets,
|
||||
ui.BIM_PT_patch,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcpatch
|
||||
from bl_operators.presets import AddPresetBase
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
import bonsai.bim.handler
|
||||
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
|
||||
return False
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
# Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes
|
||||
# become IfcBuildingElementProxy, tessellated meshes get rebuilt as
|
||||
# IfcFacetedBrep). Confirm before running so the user knows.
|
||||
if tool.Patch.migration_is_lossy_downgrade():
|
||||
return context.window_manager.invoke_props_dialog(self, width=480)
|
||||
return self.execute(context)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR")
|
||||
column = layout.column(align=True)
|
||||
column.label(text="Geometry will be preserved as faithfully as possible:")
|
||||
column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)")
|
||||
column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep")
|
||||
column.separator()
|
||||
column.label(text="The following information is lost:")
|
||||
column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy")
|
||||
column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped")
|
||||
column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')")
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Patch.get_patch_props()
|
||||
recipe_name = props.ifc_patch_recipes
|
||||
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
|
||||
query = tool.Search.get_query_for_selected_elements()
|
||||
props.ifc_patch_args_attr[0].string_value = query
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator):
|
||||
"""Save / remove ifc-patch argument presets, scoped per recipe.
|
||||
|
||||
Presets live in the standard Blender preset directory under
|
||||
``bonsai/ifc_patch/<recipe>/`` so a preset created for ``ExtractElements``
|
||||
does not pollute the preset list for ``Migrate``. Persistence across files
|
||||
and sessions is inherited from Blender's preset system."""
|
||||
|
||||
bl_idname = "bim.add_ifc_patch_preset"
|
||||
bl_label = "Add IFC Patch Preset"
|
||||
preset_menu = "BIM_MT_ifc_patch_presets"
|
||||
preset_defines = ["props = bpy.context.scene.BIMPatchProperties"]
|
||||
|
||||
@property
|
||||
def preset_subdir(self) -> str:
|
||||
return tool.Patch.get_preset_subdir()
|
||||
|
||||
@property
|
||||
def preset_values(self) -> list[str]:
|
||||
# `Attribute.get_value_name()` returns the storage field for the
|
||||
# argument's data_type (string_value, bool_value, …). For file
|
||||
# arguments it returns the wrapping PointerProperty (`filepath_value`)
|
||||
# — the scalar path the preset needs is `.single_file` on that.
|
||||
props = tool.Patch.get_patch_props()
|
||||
values = []
|
||||
for i, arg in enumerate(props.ifc_patch_args_attr):
|
||||
field = arg.get_value_name()
|
||||
if not field:
|
||||
continue
|
||||
if arg.data_type == "file":
|
||||
field = f"{field}.single_file"
|
||||
values.append(f"props.ifc_patch_args_attr[{i}].{field}")
|
||||
return values
|
||||
|
||||
@@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
|
||||
|
||||
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
|
||||
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
|
||||
# Blender's script.execute_preset mutates the menu class's bl_label to
|
||||
# the loaded preset's display name (used as a "currently selected"
|
||||
# indicator). The label persists across recipe changes — making the new
|
||||
# recipe's menu falsely show the previous recipe's preset name. Reset
|
||||
# the label to the menu's canonical title so it always matches the
|
||||
# active recipe's preset list.
|
||||
menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None)
|
||||
if menu_cls is not None:
|
||||
menu_cls.bl_label = "IFC Patch Presets"
|
||||
|
||||
|
||||
class BIMPatchProperties(PropertyGroup):
|
||||
|
||||
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.prop import Attribute
|
||||
|
||||
|
||||
class BIM_MT_ifc_patch_presets(bpy.types.Menu):
|
||||
"""Lists ifc-patch presets for the currently selected recipe.
|
||||
|
||||
``preset_subdir`` is resolved per draw so switching recipes swaps the
|
||||
preset list without re-registering the menu."""
|
||||
|
||||
bl_label = "IFC Patch Presets"
|
||||
preset_operator = "script.execute_preset"
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
self.preset_subdir = tool.Patch.get_preset_subdir()
|
||||
bpy.types.Menu.draw_preset(self, context)
|
||||
|
||||
|
||||
class BIM_PT_patch(bpy.types.Panel):
|
||||
bl_label = "Patch"
|
||||
bl_idname = "BIM_PT_patch"
|
||||
@@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel):
|
||||
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
|
||||
|
||||
if props.ifc_patch_args_attr:
|
||||
preset_row = layout.row(heading="Preset", align=True)
|
||||
preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label)
|
||||
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD")
|
||||
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True
|
||||
|
||||
draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
|
||||
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ classes = (
|
||||
operator.AppendLibraryElementByQuery,
|
||||
operator.AssignLibraryDeclaration,
|
||||
operator.BIM_FH_import_ifc,
|
||||
operator.BIM_OT_apply_pending_opening_cuts,
|
||||
operator.BIM_OT_dismiss_multi_instance_warning,
|
||||
operator.BIM_OT_dismiss_pending_array_repair,
|
||||
operator.BIM_OT_dismiss_pending_opening_cuts,
|
||||
operator.BIM_OT_select_pending_array_repair,
|
||||
operator.BIM_OT_select_pending_opening_cuts,
|
||||
operator.BIM_OT_load_clipping_planes,
|
||||
operator.BIM_OT_save_clipping_planes,
|
||||
operator.ChangeLibraryElement,
|
||||
@@ -82,6 +88,8 @@ classes = (
|
||||
prop.FilterCategory,
|
||||
prop.Link,
|
||||
prop.EditedObj,
|
||||
prop.PendingArrayRepair,
|
||||
prop.PendingOpeningRecut,
|
||||
prop.BIMProjectProperties,
|
||||
prop.MeasureToolSettings,
|
||||
ui.BIM_MT_new_project,
|
||||
|
||||
@@ -162,8 +162,8 @@ class ProjectLibraryData:
|
||||
library_file = IfcStore.library_file
|
||||
if library_file is None or library_file.schema == "IFC2X3":
|
||||
return results
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
results.append((str(project.id()), f"IfcProject {project.Name or 'Unnamed'}", project.Description or ""))
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
|
||||
for library_id, data in cls.data["project_libraries"].items():
|
||||
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
|
||||
return results
|
||||
|
||||
@@ -42,12 +42,6 @@ def toggle_decorations_on_load(*args):
|
||||
# as queried object is linked from separate .blend file.
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
class ProjectDecorator:
|
||||
installed = None
|
||||
|
||||
@@ -80,11 +74,6 @@ class ProjectDecorator:
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
special_elements_color = self.addon_prefs.decorator_color_special
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
gpu.state.point_size_set(6)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
@@ -110,7 +99,9 @@ class ProjectDecorator:
|
||||
|
||||
if geom.selected_edges:
|
||||
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
|
||||
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), geom.selected_tris)
|
||||
self.draw_batch(
|
||||
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris
|
||||
)
|
||||
|
||||
|
||||
class ClippingPlaneDecorator:
|
||||
@@ -145,11 +136,6 @@ class ClippingPlaneDecorator:
|
||||
unselected_elements_color = self.addon_prefs.decorator_color_unselected
|
||||
special_elements_color = self.addon_prefs.decorator_color_special
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
gpu.state.point_size_set(6)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
@@ -210,37 +196,21 @@ class ClippingPlaneDecorator:
|
||||
|
||||
if unselected_edges:
|
||||
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
|
||||
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
|
||||
self.draw_batch(
|
||||
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
|
||||
)
|
||||
if selected_edges:
|
||||
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
|
||||
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
|
||||
self.draw_batch(
|
||||
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
|
||||
)
|
||||
|
||||
|
||||
class MeasureDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_measurements_text, (context,), "WINDOW", "POST_PIXEL")
|
||||
)
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_measurements_poly, (context,), "WINDOW", "POST_VIEW")
|
||||
)
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
class MeasureDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_measurements_text", "POST_PIXEL"),
|
||||
("draw_measurements_poly", "POST_VIEW"),
|
||||
)
|
||||
|
||||
def draw_measurements_text(self, context):
|
||||
PolylineDecorator().select_and_draw_measurements_text(context)
|
||||
|
||||
@@ -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
|
||||
@@ -278,9 +281,9 @@ class RefreshLibrary(bpy.types.Operator):
|
||||
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
|
||||
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
|
||||
|
||||
ifc_project = library_file.by_type("IfcProject")[0]
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
hierarchy = tool.Project.get_project_hierarchy(library_file)
|
||||
tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy)
|
||||
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -630,6 +633,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"FINISHED"}
|
||||
if element.is_a("IfcTypeProduct"):
|
||||
self.harvest_opening_template(element, library_file)
|
||||
self.import_type_from_ifc(element, context)
|
||||
elif element.is_a("IfcProduct"):
|
||||
# NOTE: Non-types are not exposed in UI directly
|
||||
@@ -655,6 +659,57 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
return {"FINISHED"}
|
||||
|
||||
def harvest_opening_template(
|
||||
self, type_element: ifcopenshell.entity_instance, library_file: ifcopenshell.file
|
||||
) -> None:
|
||||
"""Seed the appended type's 'Reference' opening template from a library instance.
|
||||
|
||||
A type carries no opening of its own (openings are occurrence-level via
|
||||
IfcRelVoidsElement), so a custom opening would otherwise be lost on append and
|
||||
regenerated as a default extrusion when occurrences are placed. If the library
|
||||
file has an instance of this type whose opening is custom (non-extrusion), copy
|
||||
that opening body onto the appended type as its 'Reference' template. No-op when
|
||||
the type already carries a template (e.g. a Bonsai-authored library) or the
|
||||
library has no such instance.
|
||||
"""
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
generator = FilledOpeningGenerator()
|
||||
if generator.get_type_opening_representation(type_element):
|
||||
return
|
||||
|
||||
library_type = library_file.by_id(self.definition)
|
||||
if not library_type.is_a("IfcTypeProduct"):
|
||||
return
|
||||
|
||||
for occurrence in ifcopenshell.util.element.get_types(library_type):
|
||||
if not getattr(occurrence, "FillsVoids", None):
|
||||
continue
|
||||
opening = occurrence.FillsVoids[0].RelatingOpeningElement
|
||||
library_representation = ifcopenshell.util.representation.get_representation(
|
||||
opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
if not library_representation:
|
||||
continue
|
||||
library_representation = ifcopenshell.util.representation.resolve_representation(library_representation)
|
||||
if all(item.is_a("IfcExtrudedAreaSolid") for item in library_representation.Items):
|
||||
continue # A generated extrusion - nothing custom worth preserving.
|
||||
|
||||
project_file = tool.Ifc.get()
|
||||
representation = project_file.add(library_representation)
|
||||
# file.add brings the library's own representation context across; point the
|
||||
# copy at the project's Body context and drop the now-orphaned duplicate.
|
||||
body_context = ifcopenshell.util.representation.get_context(
|
||||
project_file, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
if body_context and representation.ContextOfItems != body_context:
|
||||
orphan_context = representation.ContextOfItems
|
||||
representation.ContextOfItems = body_context
|
||||
if not project_file.get_inverse(orphan_context):
|
||||
project_file.remove(orphan_context)
|
||||
generator.set_type_opening_representation(type_element, representation)
|
||||
return
|
||||
|
||||
def import_material_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
|
||||
self.file = tool.Ifc.get()
|
||||
logger = logging.getLogger("ImportIFC")
|
||||
@@ -760,7 +815,10 @@ class EditProjectLibrary(bpy.types.Operator):
|
||||
previous_parent_library = tool.Project.get_parent_library(project_library)
|
||||
new_parent_library = library_file.by_id(int(props.parent_library))
|
||||
if previous_parent_library != new_parent_library:
|
||||
if previous_parent_library.is_a("IfcProject"):
|
||||
if previous_parent_library is None:
|
||||
# Edited library was a root in a library-only file; nest it under the new parent.
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
elif previous_parent_library.is_a("IfcProject"):
|
||||
# Then new one is IfcProjectLibrary.
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
else: # Previous is IfcProjectLibrary.
|
||||
@@ -801,9 +859,12 @@ class AddProjectLibrary(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
library_file = IfcStore.library_file
|
||||
assert library_file
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], project)
|
||||
if root_context.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
|
||||
else:
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
|
||||
ProjectLibraryData.load() # Update enum.
|
||||
props.selected_project_library = str(project_library.id())
|
||||
props.is_editing_project_library = True
|
||||
@@ -1110,6 +1171,14 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
if not tool.Ifc.get().by_type("IfcProject"):
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
"This file contains no IfcProject. It is likely an IFC project library — "
|
||||
"load it via Project Setup → Project Library → Select Library File instead.",
|
||||
)
|
||||
IfcStore.purge()
|
||||
return {"CANCELLED"}
|
||||
props = tool.Project.get_project_props()
|
||||
props.is_loading = True
|
||||
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
|
||||
@@ -1220,6 +1289,30 @@ class LoadProjectElements(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
props.is_loading = False
|
||||
|
||||
# Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
|
||||
# The Project panel banner offers the user a one-click recut.
|
||||
props.pending_opening_recut.clear()
|
||||
if ifc_importer.gross_elements:
|
||||
for element in ifc_importer.gross_elements:
|
||||
item = props.pending_opening_recut.add()
|
||||
item.ifc_definition_id = element.id()
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
|
||||
f"Apply manually from the Project panel.",
|
||||
)
|
||||
|
||||
props.pending_array_repair.clear()
|
||||
if ifc_importer.broken_arrays:
|
||||
for element in ifc_importer.broken_arrays:
|
||||
item = props.pending_array_repair.add()
|
||||
item.ifc_definition_id = element.id()
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"{len(ifc_importer.broken_arrays)} array parent(s) reference missing child GUIDs. "
|
||||
f"Inspect from the Project panel.",
|
||||
)
|
||||
|
||||
tool.Project.load_default_thumbnails()
|
||||
tool.Project.set_default_context()
|
||||
tool.Project.set_default_modeling_dimensions()
|
||||
@@ -1381,6 +1474,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
new.ifc_definition_id = reference.id()
|
||||
new.name = filepath
|
||||
new.filepath = filepath
|
||||
new.query = self.query
|
||||
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
|
||||
|
||||
|
||||
@@ -1451,6 +1545,10 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
def _execute(self, context):
|
||||
self.link = tool.Project.get_project_props().links[self.link_index]
|
||||
# Fall back to the Link's stored query so callers that omit it
|
||||
# still replay the filter the link was created with.
|
||||
if not self.query and self.link.query:
|
||||
self.query = self.link.query
|
||||
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
|
||||
if not filepath.exists():
|
||||
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
|
||||
@@ -1618,13 +1716,36 @@ class ReloadLink(bpy.types.Operator):
|
||||
bl_description = "Reload the selected file"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
query: bpy.props.StringProperty(
|
||||
name="Query",
|
||||
description=(
|
||||
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
|
||||
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
query: str
|
||||
|
||||
def invoke(self, context, event):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
self.query = link.query
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
assert self.layout
|
||||
self.layout.prop(self, "query", placeholder="IfcElement")
|
||||
|
||||
def execute(self, context):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
# An unset query means the operator was called without the dialog
|
||||
# (e.g. from a script) - preserve the link's stored query instead
|
||||
# of overwriting it with the empty default.
|
||||
if self.properties.is_property_set("query"):
|
||||
link.query = self.query
|
||||
bpy.ops.bim.unload_link(link_index=self.link_index)
|
||||
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
|
||||
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=link.query) or {"FINISHED"}
|
||||
|
||||
|
||||
class ToggleLinkSelectability(bpy.types.Operator):
|
||||
@@ -1903,11 +2024,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 +2054,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 +2136,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 +2146,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()
|
||||
@@ -3404,3 +3539,149 @@ class GenerateUVMap(bpy.types.Operator):
|
||||
tool.Loader.load_generated_uv_map(obj.data)
|
||||
self.report({"INFO"}, "Generated UV map for selected mesh.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Recompute the wall mesh including opening subtractions for every host
|
||||
that the load-time ``void_limit`` filter skipped. Clears the deferred
|
||||
list on completion so the panel banner disappears."""
|
||||
|
||||
bl_idname = "bim.apply_pending_opening_cuts"
|
||||
bl_label = "Apply Pending Opening Cuts"
|
||||
bl_description = (
|
||||
"Recompute meshes for elements whose openings were skipped at load because they had too many openings"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
pending = tool.Project.get_project_props().pending_opening_recut
|
||||
applied = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
for item in pending:
|
||||
try:
|
||||
element = tool.Ifc.get().by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
skipped += 1
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
skipped += 1
|
||||
continue
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if body is None:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
|
||||
applied += 1
|
||||
except (RuntimeError, OSError, AttributeError) as exc:
|
||||
# Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
|
||||
failed += 1
|
||||
print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
|
||||
|
||||
pending.clear()
|
||||
message = f"Applied opening cuts to {applied} element(s)."
|
||||
if skipped:
|
||||
message += f" {skipped} entry/entries skipped (entity or object no longer available)."
|
||||
if failed:
|
||||
message += f" {failed} entry/entries failed (see system console)."
|
||||
self.report({"WARNING"}, message)
|
||||
else:
|
||||
self.report({"INFO"}, message)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_pending_opening_cuts"
|
||||
bl_label = "Dismiss Pending Opening Cuts"
|
||||
bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
tool.Project.get_project_props().pending_opening_recut.clear()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_multi_instance_warning"
|
||||
bl_label = "Dismiss Multi-Instance Warning"
|
||||
bl_description = (
|
||||
"Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
|
||||
)
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
from bonsai.bim.ifc import dismiss_multi_instance_warning
|
||||
|
||||
dismiss_multi_instance_warning()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.select_pending_opening_cuts"
|
||||
bl_label = "Select Elements With Skipped Opening Cuts"
|
||||
bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
self.report({"INFO"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
objects: list[bpy.types.Object] = []
|
||||
for item in tool.Project.get_project_props().pending_opening_recut:
|
||||
try:
|
||||
element = ifc_file.by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is not None:
|
||||
objects.append(obj)
|
||||
if not objects:
|
||||
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
|
||||
return {"CANCELLED"}
|
||||
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
|
||||
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_select_pending_array_repair(bpy.types.Operator):
|
||||
bl_idname = "bim.select_pending_array_repair"
|
||||
bl_label = "Select Array Parents With Missing Children"
|
||||
bl_description = "Select the Blender objects of array parents whose BBIM_Array.Data references children that don't resolve in the file."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
self.report({"INFO"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
objects: list[bpy.types.Object] = []
|
||||
for item in tool.Project.get_project_props().pending_array_repair:
|
||||
try:
|
||||
element = ifc_file.by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is not None:
|
||||
objects.append(obj)
|
||||
if not objects:
|
||||
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
|
||||
return {"CANCELLED"}
|
||||
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
|
||||
self.report({"INFO"}, f"Selected {len(objects)} array parent(s).")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_pending_array_repair(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_pending_array_repair"
|
||||
bl_label = "Dismiss Pending Array Repair"
|
||||
bl_description = (
|
||||
"Clear the pending array-repair list without acting on it. The underlying BBIM_Array.Data stays unchanged."
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
tool.Project.get_project_props().pending_array_repair.clear()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -98,7 +98,8 @@ def is_editing_project_library_update(self: "BIMProjectProperties", context: bpy
|
||||
project_library = library_file.by_id(int(self.selected_project_library))
|
||||
self.project_library_attributes.clear()
|
||||
bonsai.bim.helper.import_attributes(project_library, self.project_library_attributes)
|
||||
self.parent_library = str(tool.Project.get_parent_library(project_library).id())
|
||||
if parent_library := tool.Project.get_parent_library(project_library):
|
||||
self.parent_library = str(parent_library.id())
|
||||
ProjectLibraryData.load() # Show edit icon in enum.
|
||||
return
|
||||
|
||||
@@ -259,6 +260,11 @@ class Link(PropertyGroup):
|
||||
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
|
||||
default=0,
|
||||
)
|
||||
query: StringProperty(
|
||||
name="Query",
|
||||
description="Selector query used to filter elements when loading the linked model",
|
||||
default="",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: str
|
||||
@@ -274,6 +280,7 @@ class Link(PropertyGroup):
|
||||
include_in_drawings: bool
|
||||
empty_handle: Union[bpy.types.Object, None]
|
||||
ifc_definition_id: int
|
||||
query: str
|
||||
|
||||
|
||||
class EditedObj(PropertyGroup):
|
||||
@@ -295,6 +302,28 @@ class LibraryBreadcrumb(PropertyGroup):
|
||||
library_id: int
|
||||
|
||||
|
||||
class PendingOpeningRecut(PropertyGroup):
|
||||
"""One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
|
||||
and was imported without opening subtractions. The user can later apply
|
||||
them on demand from the Project panel banner."""
|
||||
|
||||
ifc_definition_id: IntProperty(name="IFC Definition ID")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifc_definition_id: int
|
||||
|
||||
|
||||
class PendingArrayRepair(PropertyGroup):
|
||||
"""One array parent whose ``BBIM_Array.Data`` references at least one
|
||||
child GUID that does not resolve in the current IFC file. The user can
|
||||
select these parents from the Project panel banner to inspect them."""
|
||||
|
||||
ifc_definition_id: IntProperty(name="IFC Definition ID")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifc_definition_id: int
|
||||
|
||||
|
||||
class BIMProjectProperties(PropertyGroup):
|
||||
is_editing: BoolProperty(name="Is Editing", default=False)
|
||||
is_loading: BoolProperty(name="Is Loading", default=False)
|
||||
@@ -360,6 +389,8 @@ class BIMProjectProperties(PropertyGroup):
|
||||
default=30,
|
||||
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
|
||||
)
|
||||
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
|
||||
pending_array_repair: CollectionProperty(name="Pending Array Repair", type=PendingArrayRepair)
|
||||
style_limit: IntProperty(
|
||||
name="Style Limit",
|
||||
default=300,
|
||||
@@ -525,6 +556,8 @@ class BIMProjectProperties(PropertyGroup):
|
||||
deflection_tolerance: float
|
||||
angular_tolerance: float
|
||||
void_limit: int
|
||||
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
|
||||
pending_array_repair: bpy.types.bpy_prop_collection_idprop[PendingArrayRepair]
|
||||
style_limit: int
|
||||
distance_limit: float
|
||||
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
|
||||
|
||||
@@ -18,43 +18,17 @@
|
||||
|
||||
import blf
|
||||
import gpu
|
||||
from bpy.types import SpaceView3D
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class GridDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (context,), "WINDOW", "POST_VIEW"))
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
class GridDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_text", "POST_PIXEL"),
|
||||
("draw", "POST_VIEW"),
|
||||
)
|
||||
|
||||
def draw_text(self, context):
|
||||
if not tool.Blender.is_addon_enabled():
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user